Browse Source

Document live trading project state

lxy 2 tuần trước cách đây
mục cha
commit
88fe09736e
36 tập tin đã thay đổi với 2272 bổ sung321 xóa
  1. 2 0
      AGENTS.md
  2. 68 22
      README.md
  3. 21 0
      deploy/high-frequency-portfolio-observer.service
  4. 73 0
      docs/architecture.md
  5. 118 0
      docs/current-status.md
  6. 112 0
      docs/documentation-guide.md
  7. 48 0
      docs/live-strategy-status-2026-05-20.md
  8. 138 0
      docs/operations.md
  9. 189 0
      okx_codex_trader/bb_squeeze_strategy.py
  10. 35 0
      okx_codex_trader/candles.py
  11. 8 1
      okx_codex_trader/live_execution.py
  12. 14 11
      okx_codex_trader/okx_client.py
  13. 106 0
      okx_codex_trader/research_metrics.py
  14. 72 0
      okx_codex_trader/time_rules.py
  15. 9 9
      reports/eth-exploration/current-strategy-recent-activity-report.md
  16. 9 9
      reports/eth-exploration/current-strategy-recent-activity.csv
  17. 33 0
      reports/live-recent/readonly-observers-snapshot.md
  18. 57 57
      reports/long-short-fusion/calendar-fusion-observation-intent.json
  19. 65 65
      reports/long-short-fusion/calendar-fusion-observation-intent.md
  20. 95 0
      reports/ultrashort/high-frequency-portfolio-observation-intent.json
  21. 114 0
      reports/ultrashort/high-frequency-portfolio-observation-intent.md
  22. 13 11
      scripts/build_calendar_fusion_observation_intent.py
  23. 152 0
      scripts/build_high_frequency_portfolio_observation_intent.py
  24. 2 1
      scripts/explore_ultrashort.py
  25. 76 124
      scripts/run_bb_squeeze_executor.py
  26. 63 0
      scripts/run_high_frequency_portfolio_observer.py
  27. 165 0
      scripts/summarize_readonly_observers.py
  28. 55 0
      tests/test_bb_squeeze_strategy.py
  29. 38 0
      tests/test_candles.py
  30. 29 0
      tests/test_explore_ultrashort.py
  31. 4 2
      tests/test_live_execution.py
  32. 23 1
      tests/test_okx_client.py
  33. 51 0
      tests/test_research_metrics.py
  34. 178 8
      tests/test_run_bb_squeeze_executor.py
  35. 27 0
      tests/test_search_eth_time_cycle_filters.py
  36. 10 0
      tests/test_summarize_readonly_observers.py

+ 2 - 0
AGENTS.md

@@ -1,4 +1,6 @@
 # Project Agent Notes
 
 - Shell commands in this repo should be run through `rtk`.
+- Start new sessions by reading `README.md`, `docs/current-status.md`, `docs/operations.md`, `docs/architecture.md`, and `docs/documentation-guide.md`.
+- Live OKX trading exists. Do not place live orders unless explicitly asked. Observer services must remain read-only.
 - For future ETH strategy exploration, use multiple `gpt-5.5` subagents in parallel when the user asks to continue exploration. The main agent should coordinate, wait for subagent results, compare outcomes, and decide the next exploration direction. Implementation and experiment execution should be delegated to subagents unless the user explicitly asks the main agent to do the work directly.

+ 68 - 22
README.md

@@ -1,34 +1,80 @@
 # okx-codex-trader
 
-Minimal project skeleton for an OKX public-data and local paper-trading CLI.
+Research and live-observation workspace for OKX crypto futures strategies.
 
-## CLI usage
+The project started as a small OKX CLI and now contains:
+
+- local OKX candle cache and data validation tools
+- strategy research and backtest scripts
+- generated research reports
+- a live ETH BB squeeze executor with strict risk caps
+- multiple read-only observer services for candidate strategies
+
+Live trading exists in this project. Treat order-submission paths as production code.
+
+## Start Here
+
+Read these files in order when opening a new session:
+
+1. `AGENTS.md` - repo-specific agent rules.
+2. `docs/current-status.md` - current live strategy, observers, recent findings, and next work.
+3. `docs/operations.md` - server, systemd services, monitoring, deployment, and safety commands.
+4. `docs/architecture.md` - package/script/report boundaries and current modularization state.
+5. `docs/documentation-guide.md` - where to put durable docs vs generated research reports.
+
+Current server:
+
+- SSH: `ubuntu@66.253.42.170`
+- App dir: `/opt/okx-codex-trader`
+- Env file: `/etc/okx-codex-trader/okx.env`
+- Main live service: `bb-squeeze-executor.service`
+
+## Repository Layout
+
+- `okx_codex_trader/` - reusable package code: OKX client, candle models, live execution planning, strategy helpers, reporting primitives.
+- `scripts/` - research searches, signal-intent builders, observers, and live runners.
+- `reports/` - generated research outputs and latest operational snapshots.
+- `docs/` - durable human-maintained project documentation.
+- `deploy/` - systemd unit files and install helpers.
+- `tests/` - unit and regression tests.
+- `data/okx-candles/` - local candle cache, not a source module.
+- `var/` - runtime state and event logs, not source.
+
+## Local Commands
+
+Use `rtk` for shell commands in this repo.
+
+```bash
+rtk .venv/bin/python -m pytest -q
+rtk .venv/bin/python scripts/summarize_readonly_observers.py
+rtk .venv/bin/python scripts/build_high_frequency_portfolio_observation_intent.py
+```
+
+Older CLI commands are still available:
 
 ```bash
-python -m okx_codex_trader.cli fetch-history --symbol BTC-USDT-SWAP --bar 1H --limit 50
-python -m okx_codex_trader.cli backtest --symbol BTC-USDT-SWAP --bar 1H --limit 200 --leverage 2
-python -m okx_codex_trader.cli analyze --symbol BTC-USDT-SWAP --bar 1H --limit 50 --output-file signal.json
-python -m okx_codex_trader.cli backtest-report --symbol BTC-USDT-SWAP --bar 1H --limit 500 --leverage 2 --output-file backtest-report.html
-python -m okx_codex_trader.cli backtest-bbmr-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --output-file bbmr-sampled-report.html
-python -m okx_codex_trader.cli backtest-bbsb-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --output-file bbsb-sampled-report.html
-python -m okx_codex_trader.cli backtest-donchian-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --entry-window 20 --exit-window 10 --stop-loss-pct 0.01 --output-file donchian-sampled-report.html
-python -m okx_codex_trader.cli backtest-rsi2-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --trend-sma 50 --rsi-length 2 --rsi-long-threshold 10 --rsi-short-threshold 90 --exit-rsi 50 --output-file rsi2-sampled-report.html
-python -m okx_codex_trader.cli backtest-ema-pullback-report --symbol BTC-USDT-SWAP --bar 3m --history-limit 5000 --leverage 2 --segments 8 --window-size 300 --fast-ema 20 --slow-ema 50 --stop-buffer-pct 0.005 --output-file ema-pullback-sampled-report.html
-python -m okx_codex_trader.cli paper-order --symbol BTC-USDT-SWAP --signal-file signal.json --margin-usdt 100
-python -m okx_codex_trader.cli positions --symbol BTC-USDT-SWAP
-OKX_TRADING_ENV=live python -m okx_codex_trader.cli okx-account --symbol BTC-USDT-SWAP --currency USDT
-OKX_TRADING_ENV=live python -m okx_codex_trader.cli okx-order --symbol BTC-USDT-SWAP --signal-file signal.json --margin-usdt 5 --max-margin-usdt 5 --confirm-live
+rtk .venv/bin/python -m okx_codex_trader.cli fetch-history --symbol BTC-USDT-SWAP --bar 1H --limit 50
+rtk .venv/bin/python -m okx_codex_trader.cli okx-account --symbol ETH-USDT-SWAP --currency USDT
 ```
 
-Supported symbols are `BTC-USDT-SWAP` and `ETH-USDT-SWAP`. Backtest leverage is restricted to `1`, `2`, or `3`.
+Authenticated OKX commands require `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_API_PASSPHRASE`, and `OKX_TRADING_ENV=demo|live`.
 
-Sampled reports generate one self-contained HTML file with switchable sampled windows, trade journals, price/equity charts, and aggregate metrics.
+## Live Safety
 
-`fetch-history`, `backtest`, and `paper-order` use public OKX market data only. `paper-order` and `positions` persist local simulated state in `paper_state.json`.
+- Do not place live orders unless explicitly asked.
+- The deployed live path is intentionally small risk.
+- Read-only observers must keep `submitted_orders: 0` and must not call OKX order APIs.
+- Any strategy promotion requires a fresh read-only observation period and backtest review.
+
+## Latest Operational Snapshot
+
+The latest observer snapshot is generated by:
+
+```bash
+rtk .venv/bin/python scripts/summarize_readonly_observers.py
+```
 
-Authenticated OKX commands require `OKX_API_KEY`, `OKX_API_SECRET`, `OKX_API_PASSPHRASE`, and `OKX_TRADING_ENV=demo|live`. Live orders require `--confirm-live`; every OKX order command also requires `--max-margin-usdt`, and rejects larger `--margin-usdt`.
+Current checked-in snapshot:
 
-## Verification notes
+- `reports/live-recent/readonly-observers-snapshot.md`
 
-- Public OKX market-data calls were exercised in automated tests through the client contract and CLI flow.
-- Local `codex` runtime behavior outside mocked subprocess tests still requires manual verification.

+ 21 - 0
deploy/high-frequency-portfolio-observer.service

@@ -0,0 +1,21 @@
+[Unit]
+Description=OKX high-frequency portfolio readonly observer
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+WorkingDirectory=/opt/okx-codex-trader
+Environment=PYTHONUNBUFFERED=1
+ExecStart=/opt/okx-codex-trader/.venv/bin/python scripts/run_high_frequency_portfolio_observer.py --interval-seconds 300
+Restart=always
+RestartSec=20
+User=okxbot
+Group=okxbot
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ReadWritePaths=/opt/okx-codex-trader/data /opt/okx-codex-trader/reports /opt/okx-codex-trader/var
+
+[Install]
+WantedBy=multi-user.target

+ 73 - 0
docs/architecture.md

@@ -0,0 +1,73 @@
+# Architecture
+
+The project has three layers:
+
+1. `okx_codex_trader/`: reusable trading, data, reporting, and research primitives.
+2. `scripts/`: experiment entrypoints and live runners. A script can define a strategy variant and grid, but should import shared candle loading, time rules, and metrics from the package.
+3. `reports/`: generated outputs only.
+
+## Reusable Modules
+
+- `okx_codex_trader/candles.py`
+  - Loads OKX candle CSV files into `Candle`.
+  - Aligns candle streams by timestamp for cross-symbol strategies.
+
+- `okx_codex_trader/time_rules.py`
+  - Owns UTC, New York, and Beijing session rules.
+  - Provides entry filters and session buckets used by time-cycle strategies.
+  - US session names are clock windows, not an exchange holiday calendar.
+
+- `okx_codex_trader/research_metrics.py`
+  - Owns common research accounting constants.
+  - Builds cost-adjusted equity curves.
+  - Computes total return, annualized return, drawdown, Calmar, horizon rows, trade stats, and worst month.
+
+## Current Boundary
+
+Strategy mechanics still live in individual scripts because the search space is changing quickly. The first extracted script is `scripts/search_eth_time_cycle_filters.py`; future strategy searches should follow that shape:
+
+1. Define only the strategy variant and signal logic in the script.
+2. Load candles through `load_candles_csv`.
+3. Use `time_rules` for all session filters.
+4. Use `research_metrics` for all net-return, horizon, and trade-summary statistics.
+
+This keeps experiments easy to compose without forcing a generic strategy framework before the live strategy stabilizes.
+
+Currently migrated strategy searches:
+
+- `scripts/search_eth_time_cycle_filters.py`
+- `scripts/search_eth_bb_squeeze_fixed_rr.py`
+- `scripts/search_eth_bb_squeeze_profit_protection.py`
+
+`scripts/search_eth_dynamic_atr_exits.py` still keeps local metrics because it reports `full` horizon and horizon-level trade stats from `exit_ts`; extracting that needs a separate direct change to `research_metrics`.
+
+## Multi-Timeframe Execution Research
+
+The current live BB squeeze strategy uses 15m candles for both signals and research execution. That is intentionally simple, but it makes entry and exit sequencing coarse: a 15m candle only gives open, high, low, and close, not the local path.
+
+The next research step is a two-layer model:
+
+1. Keep 15m candles for the primary strategy signal.
+2. Use local 3m candles inside each 15m decision window to model execution and exits.
+
+The first scope is exit-only refinement:
+
+- 15m still decides whether a position should exist.
+- 3m checks stop-loss, take-profit, and breakeven activation/exit ordering while the 15m position is open.
+- 15m middle-exit remains a next-open signal exit unless a 3m risk exit happens first.
+
+Entry refinement is a later step. It should only be tested after the exit-only version is measured, because changing entry and exit together makes attribution unclear.
+
+Current 3m entry research tested three local execution families:
+
+- 3m momentum confirmation: wait for the first 3m candle in the execution window to close in the signal direction, then enter on the next 3m open.
+- 3m pullback entry: wait for a 0.05%-0.3% local pullback, then enter on the next 3m open.
+- 3m TWAP entry: use the average of the first 2-5 3m opens as the entry price.
+
+The first result is not enough to replace the live execution rule:
+
+- 3m risk-only improves full-period results against the 15m baseline without changing recent windows.
+- Pullback entry, especially 0.1%-0.2%, improves recent 1y/6m/3m windows, but weakens full-period robustness.
+- TWAP entry improves some recent windows but materially weakens full-period performance.
+
+The next direct research path is a regime-gated 3m entry selector: use 15m baseline or 3m risk-only as the default, and only enable pullback entry in regimes where the recent market structure supports it.

+ 118 - 0
docs/current-status.md

@@ -0,0 +1,118 @@
+# Current Status
+
+Last updated: 2026-05-25 Asia/Shanghai.
+
+## Live Trading
+
+Main live service:
+
+- Service: `bb-squeeze-executor.service`
+- Host: `ubuntu@66.253.42.170`
+- App dir: `/opt/okx-codex-trader`
+- Symbol: `ETH-USDT-SWAP`
+- Bar: `15m`
+- Strategy module: `okx_codex_trader/bb_squeeze_strategy.py`
+- Runner: `scripts/run_bb_squeeze_executor.py`
+
+The service is live-capable and has submitted real OKX orders before. It must remain small-risk unless explicitly changed.
+
+Current live strategy family:
+
+- BB squeeze, both long/short.
+- 15m completed-candle decision loop.
+- Exchange-side stop-loss attachment is required for opening orders.
+- No fixed exchange-side take-profit is currently part of the live strategy.
+- Strategy exits are primarily signal/middle-band exits plus stop-loss.
+
+Recent server backtest snapshot, data through `2026-05-24T16:15:00Z`:
+
+| strategy | 30d | 14d | 7d | trades_30d |
+| --- | ---: | ---: | ---: | ---: |
+| live_bb_squeeze_mxbuf0.0005 | -9.99% | -5.42% | -3.22% | 23 |
+| bb_squeeze_t_gated_tre96 | -10.22% | -9.32% | -9.32% | 0 |
+| crash_follow_short | 0.00% | 0.00% | 0.00% | 0 |
+
+Source:
+
+- `reports/eth-exploration/current-strategy-recent-activity-report.md`
+- Historical deployment note: `docs/live-strategy-status-2026-05-20.md`
+
+## Read-only Observers
+
+Currently active observer services on the server:
+
+| service | purpose | current readout |
+| --- | --- | --- |
+| `bb-squeeze-t-gated-observer.service` | T-gated BB squeeze variant | Mostly state replay; 1 entry in latest 7d snapshot. |
+| `calendar-fusion-observer.service` | Long/short calendar fusion candidate | Recently fixed after stale candidate failure; needs fresh observation. |
+| `crash-follow-short-observer.service` | Crash-follow short candidate | Healthy, but no recent entry. |
+| `eth-focused-portfolio-observer.service` | ETH-focused conservative portfolio | Has recent ETH entry signals; remains read-only. |
+| `eth-nextgen-micro-observer.service` | ETH nextgen + micro switch | Has sparse recent long entries; remains read-only. |
+| `high-frequency-portfolio-observer.service` | Cross-symbol high-frequency portfolio `risk-3-hf00486` | Newly added; current target flat. |
+| `short-bias-readonly-observer.service` | Short-bias overlay | Has sparse recent short entries. |
+
+Latest unified snapshot:
+
+- `reports/live-recent/readonly-observers-snapshot.md`
+
+Generate it on the server or locally with available `var/` logs:
+
+```bash
+rtk .venv/bin/python scripts/summarize_readonly_observers.py
+```
+
+## Most Recent Changes
+
+- Fixed `calendar-fusion-observer`: it no longer reads an empty selected-candidate CSV with stale hardcoded names. It now selects top observation candidates from `reports/long-short-fusion/fusion-calendar-total.csv` by explicit rule.
+- Added high-frequency portfolio read-only observer for `risk-3-hf00486`.
+- Added unified read-only observer snapshot generator.
+- Deployed `high-frequency-portfolio-observer.service` to the server.
+- Confirmed `bb-squeeze-executor.service`, `calendar-fusion-observer.service`, and `high-frequency-portfolio-observer.service` are active after deployment.
+
+## Current Strategy Ranking Read
+
+Near-term replacement is not justified by current evidence.
+
+Important candidates:
+
+1. `risk-3-hf00486` high-frequency portfolio
+   - Report: `reports/ultrashort/high-frequency-portfolio-report.md`
+   - Full return: `+114.17%`
+   - 1y: `+22.94%`
+   - 6m: `+2.78%`
+   - 3m: `+7.39%`
+   - Frequency: about `46.4` trades/month
+   - Status: now read-only observed.
+
+2. ETH-focused conservative portfolio
+   - Report: `reports/eth-exploration/eth-focused-portfolio-conservative-report.md`
+   - Existing observer: `eth-focused-portfolio-observer.service`
+   - Status: read-only, recent entries observed.
+
+3. ETH nextgen + micro
+   - Report: `reports/eth-exploration/eth-nextgen-micro-portfolio-report.md`
+   - Existing observer: `eth-nextgen-micro-observer.service`
+   - Status: read-only, sparse recent entries.
+
+4. Short-bias overlay
+   - Report: `reports/short-bias/overlay-mix-report.md`
+   - Existing observer: `short-bias-readonly-observer.service`
+   - Status: read-only, sparse short entries.
+
+## Open Work
+
+- Let the newly added high-frequency portfolio observer accumulate forward logs before considering promotion.
+- Continue monitoring recent BB squeeze degradation before changing live risk.
+- Keep improving modularity by moving reusable research accounting into `okx_codex_trader/`.
+- If a strategy is promoted from observer to executor, first design target-position reconciliation and exchange-side risk orders.
+
+## Safety Boundary
+
+Do not convert any observer into a live executor by adding order calls in-place. A live promotion needs a separate executor path with:
+
+- explicit margin caps
+- idempotent candle processing
+- position reconciliation
+- OKX fill and fee logging
+- exchange-side stop-loss handling
+- tests for long, short, flat, reduce, and no-op states

+ 112 - 0
docs/documentation-guide.md

@@ -0,0 +1,112 @@
+# Documentation Guide
+
+This repo has many generated reports. Keep durable project knowledge in `docs/`; keep experiment outputs in `reports/`.
+
+## Durable Docs
+
+Use these files for human-maintained project state:
+
+- `README.md`
+  - Project entrypoint.
+  - Links to the current docs.
+  - High-level layout and safety notes.
+
+- `AGENTS.md`
+  - Agent-specific operating instructions.
+  - Keep it short and stable.
+
+- `docs/current-status.md`
+  - Latest live strategy state.
+  - Active services and observers.
+  - Recent performance readout.
+  - Open work and safety boundary.
+
+- `docs/operations.md`
+  - Server details.
+  - systemd service names.
+  - Monitoring and deployment commands.
+  - Runtime paths.
+
+- `docs/architecture.md`
+  - Code boundaries.
+  - Which logic belongs in package modules vs scripts.
+  - Current modularization state.
+
+- `docs/documentation-guide.md`
+  - This file.
+
+Older dated status docs can remain for audit history, but the new-session handoff should point to `docs/current-status.md`.
+
+## Generated Reports
+
+Reports are outputs, not source-of-truth prose. Keep them under `reports/` by topic:
+
+- `reports/live-recent/`
+  - Recent operational snapshots and live strategy comparisons.
+  - Current observer summary: `readonly-observers-snapshot.md`.
+
+- `reports/eth-exploration/`
+  - ETH-focused backtests, strategy searches, signal intents, and execution research.
+
+- `reports/ultrashort/`
+  - Ultra-short and high-frequency strategy research.
+  - Current high-frequency observation intent lives here because it is generated.
+
+- `reports/long-short-fusion/`
+  - Long/short fusion and calendar fusion research.
+
+- `reports/short-bias/`
+  - Short-biased strategy research.
+
+- `reports/recent-regime/`
+  - Recent-market and regime adaptation research.
+
+- `reports/strategy-expansion/`
+  - Broader non-ultrashort strategy research.
+
+## Naming Rules
+
+For durable docs:
+
+- Use stable names: `current-status.md`, `operations.md`, `architecture.md`.
+- Avoid date suffixes unless the file is intentionally historical.
+
+For generated reports:
+
+- Keep the script prefix in the filename when possible.
+- Use `*-report.md` for human summaries.
+- Use `*-summary.csv`, `*-horizon.csv`, `*-trades.csv`, or `*-equity.csv` for machine outputs.
+- Use `*-intent.json` and `*-intent.md` for live/observer signal payload snapshots.
+
+## Update Rules
+
+When live services or observers change:
+
+1. Update `docs/current-status.md`.
+2. Update `docs/operations.md` if service names, paths, or commands changed.
+3. Generate `reports/live-recent/readonly-observers-snapshot.md`.
+4. Keep generated JSON/CSV/MD reports with the script that produced them.
+
+When adding a new strategy search:
+
+1. Put reusable code in `okx_codex_trader/` only if another script needs it now.
+2. Put the search entrypoint in `scripts/`.
+3. Write outputs under the relevant `reports/<topic>/` directory.
+4. Add a short report `.md` that records command, data range, costs, and decision.
+5. Update `docs/current-status.md` only if the result changes the operational direction.
+
+When adding a new live or observer service:
+
+1. Add the runner script under `scripts/`.
+2. Add the systemd unit under `deploy/`.
+3. Add heartbeat and event paths to `docs/operations.md`.
+4. Add the service to `scripts/summarize_readonly_observers.py` if it is an observer.
+5. Add a regression test for any classification or order-planning logic.
+
+## What Not To Do
+
+- Do not put long generated CSV tables in `docs/`.
+- Do not duplicate current status across multiple dated docs.
+- Do not make observers submit orders.
+- Do not treat local reports as fresher than server-generated live snapshots without checking candle end times.
+

+ 48 - 0
docs/live-strategy-status-2026-05-20.md

@@ -0,0 +1,48 @@
+# Live Strategy Status 2026-05-20
+
+Remote service: `bb-squeeze-executor.service`
+
+Remote host: `ubuntu@66.253.42.170`
+
+Live executor strategy:
+
+`bb-rr-time-l96-bw480-q0.15-sl0.01-rr3-hybrid_signal_rr-both-none-vc0.006-ddnone-cd24-mxbuf0.001-mxc1-entryweekday-openexitskip`
+
+Parameters:
+
+- Symbol: `ETH-USDT-SWAP`
+- Bar: `15m`
+- Leverage: `3`
+- Band length: `96`
+- Bandwidth lookback: `480`
+- Bandwidth quantile: `0.15`
+- Stop loss: `1%`
+- Take profit: `3%`
+- Middle exit buffer: `0.1%`
+- Entry time filter: New York weekday
+- US open middle-exit mode: skip
+- BTC filter: `btc-up`
+- ETH realized volatility cap: `0.006`
+- Cooldown: `24` bars
+
+Risk limits remain environment-controlled:
+
+- Margin per unit: `100 USDT`
+- Max new margin: `100 USDT`
+- Max total margin: `200 USDT`
+
+Deployment verification:
+
+- Service active after restart.
+- Latest event at `2026-05-20T17:04:09Z`.
+- Current position: flat.
+- Latest signal: hold.
+- Orders submitted on verification event: `0`.
+- Runtime state includes `middle_exit_streak`.
+
+Research basis:
+
+- `reports/eth-exploration/eth-fixed-rr-time-filters-summary.csv`
+- `reports/eth-exploration/eth-fixed-rr-time-filters-report.md`
+
+The best time-filter overlay for the top BTC-up fixed R:R candidate remained `entry=all`, so that overlay was not adopted. The deployed version uses the best directly real-time-closed candidate that does not require account-equity drawdown overlay state.

+ 138 - 0
docs/operations.md

@@ -0,0 +1,138 @@
+# Operations
+
+## Environment
+
+Remote server:
+
+- SSH: `ubuntu@66.253.42.170`
+- App dir: `/opt/okx-codex-trader`
+- Runtime user: `okxbot`
+- Env file: `/etc/okx-codex-trader/okx.env`
+- Local repo: `/home/lxy/okx-codex-trader`
+
+Do not print secrets from the env file. Only check whether required keys exist when needed.
+
+## Services
+
+Live executor:
+
+- `bb-squeeze-executor.service`
+
+Read-only observers:
+
+- `bb-squeeze-t-gated-observer.service`
+- `calendar-fusion-observer.service`
+- `crash-follow-short-observer.service`
+- `eth-focused-portfolio-observer.service`
+- `eth-nextgen-micro-observer.service`
+- `high-frequency-portfolio-observer.service`
+- `short-bias-readonly-observer.service`
+
+Historical or dry-run service files may exist in `deploy/`, but the list above is the current operational set.
+
+## Monitoring
+
+Service status:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'systemctl status bb-squeeze-executor.service --no-pager -l'
+rtk ssh ubuntu@66.253.42.170 'systemctl status high-frequency-portfolio-observer.service --no-pager -l'
+```
+
+All relevant services:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'systemctl list-units --type=service --all --no-pager | grep -Ei "bb-squeeze|observer|eth|short-bias|calendar|crash|high-frequency"'
+```
+
+Recent live executor events:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && tail -n 80 var/bb-squeeze-executor/events.jsonl'
+```
+
+Unified observer snapshot:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && sudo -u okxbot .venv/bin/python scripts/summarize_readonly_observers.py && sed -n "1,120p" reports/live-recent/readonly-observers-snapshot.md'
+```
+
+Individual heartbeats:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && python3 -m json.tool var/high-frequency-portfolio/heartbeat.json | sed -n "1,160p"'
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && python3 -m json.tool var/calendar-fusion/heartbeat.json | sed -n "1,160p"'
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && python3 -m json.tool var/short-bias-readonly/heartbeat.json | sed -n "1,160p"'
+```
+
+## Deploying Script Changes
+
+For a targeted script-only deploy:
+
+```bash
+rtk scp scripts/<script>.py ubuntu@66.253.42.170:/tmp/<script>.py
+rtk ssh ubuntu@66.253.42.170 'sudo install -o okxbot -g okxbot -m 0755 /tmp/<script>.py /opt/okx-codex-trader/scripts/<script>.py'
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && sudo -u okxbot .venv/bin/python -m py_compile scripts/<script>.py'
+```
+
+Restart only the affected service:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'sudo systemctl restart <service>.service && sleep 3 && systemctl status <service>.service --no-pager -l'
+```
+
+For a new observer service, add the service file to `deploy/`, install it to `/etc/systemd/system/`, run `systemctl daemon-reload`, then `enable --now`.
+
+## Current High-frequency Observer
+
+Files:
+
+- `scripts/build_high_frequency_portfolio_observation_intent.py`
+- `scripts/run_high_frequency_portfolio_observer.py`
+- `deploy/high-frequency-portfolio-observer.service`
+
+Service:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'systemctl status high-frequency-portfolio-observer.service --no-pager -l'
+```
+
+It is read-only. It writes:
+
+- `var/high-frequency-portfolio/heartbeat.json`
+- `var/high-frequency-portfolio/observer-events.jsonl`
+- `reports/ultrashort/high-frequency-portfolio-observation-intent.json`
+- `reports/ultrashort/high-frequency-portfolio-observation-intent.md`
+
+## Current Calendar Fusion Observer
+
+Files:
+
+- `scripts/build_calendar_fusion_observation_intent.py`
+- `scripts/run_calendar_fusion_observer.py`
+
+The builder selects observation candidates from `reports/long-short-fusion/fusion-calendar-total.csv`. If no candidate matches the explicit rule, it raises an error instead of silently using stale candidates.
+
+## Data and Reports
+
+Local and server candle caches can differ. Recent operational decisions should use the server cache when evaluating live services.
+
+Server refresh/check examples:
+
+```bash
+rtk ssh ubuntu@66.253.42.170 'cd /opt/okx-codex-trader && sudo -u okxbot .venv/bin/python scripts/summarize_current_strategy_recent_activity.py'
+rtk scp ubuntu@66.253.42.170:/opt/okx-codex-trader/reports/eth-exploration/current-strategy-recent-activity-report.md reports/eth-exploration/current-strategy-recent-activity-report.md
+```
+
+## Live Order Safety
+
+Before touching live trading:
+
+- Check current account and positions first.
+- Do not disturb existing positions unless explicitly requested.
+- Keep single-order and total exposure caps.
+- Do not promote observer code by adding order submission inside it.
+- Prefer a separate executor that consumes a tested signal payload.
+
+The live BB squeeze executor is the only current live order service. Observer services must remain read-only.
+

+ 189 - 0
okx_codex_trader/bb_squeeze_strategy.py

@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pandas as pd
+
+from okx_codex_trader.time_rules import entry_allowed, is_us_open_window
+
+
+BAND_LENGTH = 96
+BANDWIDTH_LOOKBACK = 960
+BANDWIDTH_QUANTILE = 0.25
+STOP_LOSS_PCT = 0.01
+TAKE_PROFIT_PCT = 0.03
+MIDDLE_EXIT_BUFFER_PCT = 0.001
+MIDDLE_EXIT_CONFIRM_BARS = 1
+ETH_VOL_CAP = 0.006
+COOLDOWN_BARS = 24
+BTC_TREND_SMA = 480
+BTC_MOMENTUM_LOOKBACK = 96
+ENTRY_TIME_FILTER = "weekday"
+US_OPEN_EXIT_MODE = "skip"
+BREAKEVEN_TRIGGER_PCT = 0.008
+BREAKEVEN_LOCK_PCT = 0.001
+
+
+@dataclass(frozen=True)
+class StrategyState:
+    last_candle_ts: int | None
+    active_side: str | None
+    entry_price: float | None
+    entry_candle_ts: int | None
+    cooldown_until_ts: int | None
+    middle_exit_streak: int
+    max_favorable_move_pct: float | None
+
+
+EMPTY_STATE = StrategyState(None, None, None, None, None, 0, None)
+
+
+def strategy_name() -> str:
+    return (
+        f"bb-rr-time-l{BAND_LENGTH}-bw{BANDWIDTH_LOOKBACK}"
+        f"-q{BANDWIDTH_QUANTILE:g}-sl{STOP_LOSS_PCT:g}-rr{TAKE_PROFIT_PCT / STOP_LOSS_PCT:g}"
+        f"-hybrid_signal_rr-both-btc-up-vc{ETH_VOL_CAP:g}-ddnone"
+        f"-cd{COOLDOWN_BARS}-mxbuf{MIDDLE_EXIT_BUFFER_PCT:g}-mxc{MIDDLE_EXIT_CONFIRM_BARS}"
+        f"-entry{ENTRY_TIME_FILTER}-openexit{US_OPEN_EXIT_MODE}"
+        f"-be{BREAKEVEN_TRIGGER_PCT:g}-{BREAKEVEN_LOCK_PCT:g}"
+    )
+
+
+def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
+    if len(frame) < BANDWIDTH_LOOKBACK + 2 or "btc_close" not in frame.columns:
+        raise ValueError("not enough candles")
+    close = frame["close"].astype(float)
+    btc_close = frame["btc_close"].astype(float)
+    middle = close.rolling(BAND_LENGTH).mean()
+    stdev = close.rolling(BAND_LENGTH).std(ddof=0)
+    upper = middle + (2.0 * stdev)
+    lower = middle - (2.0 * stdev)
+    bandwidth = (upper - lower) / middle
+    threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
+    eth_vol = close.pct_change().rolling(96).std(ddof=0)
+    btc_sma = btc_close.rolling(BTC_TREND_SMA).mean()
+    btc_momentum = btc_close / btc_close.shift(BTC_MOMENTUM_LOOKBACK) - 1.0
+    decision_index = len(frame) - 1
+    row = frame.iloc[decision_index]
+    candle_ts = int(row["ts"])
+    candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
+
+    indicators = {
+        "eth_close": float(row["close"]),
+        "middle": float(middle.iloc[decision_index]),
+        "upper": float(upper.iloc[decision_index]),
+        "lower": float(lower.iloc[decision_index]),
+        "bandwidth": float(bandwidth.iloc[decision_index]),
+        "bandwidth_threshold": float(threshold.iloc[decision_index]),
+        "eth_vol_96": float(eth_vol.iloc[decision_index]),
+        "btc_close": float(btc_close.iloc[decision_index]),
+        "btc_sma_480": float(btc_sma.iloc[decision_index]),
+        "btc_momentum_96": float(btc_momentum.iloc[decision_index]),
+    }
+
+    if state.last_candle_ts is not None and candle_ts <= state.last_candle_ts:
+        return state, {
+            "decision_candle_ts": candle_ts,
+            "decision_candle_time": candle_time,
+            "signal": "state_replay",
+            "target_side": state.active_side or "flat",
+            "indicators": indicators,
+        }
+
+    next_state = StrategyState(
+        candle_ts,
+        state.active_side,
+        state.entry_price,
+        state.entry_candle_ts,
+        state.cooldown_until_ts,
+        state.middle_exit_streak,
+        state.max_favorable_move_pct,
+    )
+    signal = "hold"
+    target_side = state.active_side or "flat"
+
+    if state.active_side is not None:
+        entry_price = float(state.entry_price)
+        stop = entry_price * (1.0 - STOP_LOSS_PCT if state.active_side == "long" else 1.0 + STOP_LOSS_PCT)
+        if state.max_favorable_move_pct is not None and state.max_favorable_move_pct >= BREAKEVEN_TRIGGER_PCT:
+            breakeven_stop = entry_price * (1.0 + BREAKEVEN_LOCK_PCT if state.active_side == "long" else 1.0 - BREAKEVEN_LOCK_PCT)
+            stop = max(stop, breakeven_stop) if state.active_side == "long" else min(stop, breakeven_stop)
+        take = entry_price * (1.0 + TAKE_PROFIT_PCT if state.active_side == "long" else 1.0 - TAKE_PROFIT_PCT)
+        stop_hit = (state.active_side == "long" and float(row["low"]) <= stop) or (state.active_side == "short" and float(row["high"]) >= stop)
+        take_hit = (state.active_side == "long" and float(row["high"]) >= take) or (state.active_side == "short" and float(row["low"]) <= take)
+        middle_exit = (state.active_side == "long" and float(row["close"]) < indicators["middle"] * (1.0 - MIDDLE_EXIT_BUFFER_PCT)) or (
+            state.active_side == "short" and float(row["close"]) > indicators["middle"] * (1.0 + MIDDLE_EXIT_BUFFER_PCT)
+        )
+        if middle_exit and is_us_open_window(candle_ts) and US_OPEN_EXIT_MODE == "skip":
+            middle_exit = False
+        middle_exit_streak = state.middle_exit_streak + 1 if middle_exit else 0
+        favorable_move = (float(row["high"]) / entry_price - 1.0) if state.active_side == "long" else (entry_price / float(row["low"]) - 1.0)
+        max_favorable_move_pct = max(float(state.max_favorable_move_pct or 0.0), favorable_move)
+        next_state = StrategyState(
+            candle_ts,
+            state.active_side,
+            state.entry_price,
+            state.entry_candle_ts,
+            state.cooldown_until_ts,
+            middle_exit_streak,
+            max_favorable_move_pct,
+        )
+        if stop_hit or take_hit or middle_exit_streak >= MIDDLE_EXIT_CONFIRM_BARS:
+            signal = (
+                "exit_breakeven"
+                if stop_hit and state.max_favorable_move_pct is not None and state.max_favorable_move_pct >= BREAKEVEN_TRIGGER_PCT
+                else "exit_stop"
+                if stop_hit
+                else "exit_take_profit"
+                if take_hit
+                else "exit_middle"
+            )
+            target_side = "flat"
+            next_state = StrategyState(
+                candle_ts,
+                None,
+                None,
+                None,
+                candle_ts + (COOLDOWN_BARS * 900_000),
+                0,
+                None,
+            )
+    else:
+        cooldown_ok = state.cooldown_until_ts is None or candle_ts >= state.cooldown_until_ts
+        compressed = indicators["bandwidth"] <= indicators["bandwidth_threshold"]
+        vol_ok = indicators["eth_vol_96"] <= ETH_VOL_CAP
+        btc_up = indicators["btc_close"] > indicators["btc_sma_480"]
+        time_ok = entry_allowed(candle_ts, ENTRY_TIME_FILTER)
+        if cooldown_ok and compressed and vol_ok and btc_up and time_ok and float(row["close"]) > indicators["upper"]:
+            signal = "entry_long"
+            target_side = "long"
+            next_state = StrategyState(candle_ts, "long", float(row["close"]), candle_ts, state.cooldown_until_ts, 0, 0.0)
+        elif cooldown_ok and compressed and vol_ok and btc_up and time_ok and float(row["close"]) < indicators["lower"]:
+            signal = "entry_short"
+            target_side = "short"
+            next_state = StrategyState(candle_ts, "short", float(row["close"]), candle_ts, state.cooldown_until_ts, 0, 0.0)
+
+    return next_state, {
+        "decision_candle_ts": candle_ts,
+        "decision_candle_time": candle_time,
+        "signal": signal,
+        "target_side": target_side,
+        "indicators": indicators,
+        "params": {
+            "band_length": BAND_LENGTH,
+            "bandwidth_lookback": BANDWIDTH_LOOKBACK,
+            "bandwidth_quantile": BANDWIDTH_QUANTILE,
+            "stop_loss_pct": STOP_LOSS_PCT,
+            "take_profit_pct": TAKE_PROFIT_PCT,
+            "middle_exit_buffer_pct": MIDDLE_EXIT_BUFFER_PCT,
+            "middle_exit_confirm_bars": MIDDLE_EXIT_CONFIRM_BARS,
+            "eth_vol_cap": ETH_VOL_CAP,
+            "cooldown_bars": COOLDOWN_BARS,
+            "side_mode": "both",
+            "btc_filter": "btc-up",
+            "entry_time_filter": ENTRY_TIME_FILTER,
+            "us_open_exit_mode": US_OPEN_EXIT_MODE,
+            "breakeven_trigger_pct": BREAKEVEN_TRIGGER_PCT,
+            "breakeven_lock_pct": BREAKEVEN_LOCK_PCT,
+        },
+    }

+ 35 - 0
okx_codex_trader/candles.py

@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from okx_codex_trader.models import Candle
+
+
+def load_candles_csv(data_dir: Path, symbol: str, bar: str) -> list[Candle]:
+    frame = pd.read_csv(data_dir / symbol / f"{bar}.csv")
+    return [
+        Candle(
+            symbol=symbol,
+            ts=int(row.ts),
+            open=float(row.open),
+            high=float(row.high),
+            low=float(row.low),
+            close=float(row.close),
+            volume=float(row.volume),
+        )
+        for row in frame.itertuples(index=False)
+    ]
+
+
+def align_candles_by_ts(left: list[Candle], right: list[Candle]) -> tuple[list[Candle], list[Candle]]:
+    right_by_ts = {candle.ts: candle for candle in right}
+    left_out: list[Candle] = []
+    right_out: list[Candle] = []
+    for candle in left:
+        other = right_by_ts.get(candle.ts)
+        if other is not None:
+            left_out.append(candle)
+            right_out.append(other)
+    return left_out, right_out

+ 8 - 1
okx_codex_trader/live_execution.py

@@ -48,7 +48,7 @@ class ExecutionPlan:
 class RenderedOrder:
     action: str
     margin_usdt: float
-    body: dict[str, str]
+    body: dict[str, object]
 
 
 EMPTY_STATE = RuntimeState(last_candle_ts=None, nextgen_active_legs=(), micro_side=None)
@@ -192,11 +192,14 @@ def render_market_order_bodies(
     max_total_margin_usdt: float,
     client_order_id_prefix: str,
     stop_loss_pct: float | None = None,
+    take_profit_pct: float | None = None,
 ) -> tuple[RenderedOrder, ...]:
     if leverage <= 0 or margin_per_unit_usdt <= 0.0 or max_new_margin_usdt < 0.0 or max_total_margin_usdt < 0.0:
         raise ValueError("order rendering inputs are invalid")
     if stop_loss_pct is not None and stop_loss_pct <= 0.0:
         raise ValueError("stop_loss_pct is invalid")
+    if take_profit_pct is not None and take_profit_pct <= 0.0:
+        raise ValueError("take_profit_pct is invalid")
     if plan.target.known and plan.target.unit * margin_per_unit_usdt > max_total_margin_usdt:
         raise ValueError("target margin exceeds max_total_margin_usdt")
     rendered: list[RenderedOrder] = []
@@ -220,8 +223,11 @@ def render_market_order_bodies(
         else:
             size = build_contract_size(margin * leverage, mark_price, metadata)
         stop_loss_trigger_price = None
+        take_profit_trigger_price = None
         if not action.reduce_only and stop_loss_pct is not None:
             stop_loss_trigger_price = mark_price * (1.0 - stop_loss_pct if action.side == "long" else 1.0 + stop_loss_pct)
+        if not action.reduce_only and take_profit_pct is not None:
+            take_profit_trigger_price = mark_price * (1.0 + take_profit_pct if action.side == "long" else 1.0 - take_profit_pct)
         rendered.append(
             RenderedOrder(
                 action=action.action,
@@ -234,6 +240,7 @@ def render_market_order_bodies(
                     client_order_id=market_client_order_id(client_order_id_prefix, index, action.action),
                     reduce_only=action.reduce_only,
                     stop_loss_trigger_price=stop_loss_trigger_price,
+                    take_profit_trigger_price=take_profit_trigger_price,
                 ),
             )
         )

+ 14 - 11
okx_codex_trader/okx_client.py

@@ -114,7 +114,7 @@ class OkxClient:
         price: object,
         size: object,
         client_order_id: str,
-    ) -> dict[str, str]:
+    ) -> dict[str, object]:
         if action not in {"long", "short"}:
             raise ValueError("action is invalid")
         return {
@@ -183,12 +183,13 @@ class OkxClient:
         client_order_id: str,
         reduce_only: bool,
         stop_loss_trigger_price: object | None = None,
+        take_profit_trigger_price: object | None = None,
     ) -> dict[str, str]:
         if side not in {"buy", "sell"}:
             raise ValueError("side is invalid")
         if pos_side not in {"long", "short"}:
             raise ValueError("pos_side is invalid")
-        body = {
+        body: dict[str, object] = {
             "instId": symbol,
             "tdMode": "isolated",
             "side": side,
@@ -199,15 +200,17 @@ class OkxClient:
         }
         if reduce_only:
             body["reduceOnly"] = "true"
-        if stop_loss_trigger_price is not None:
+        if stop_loss_trigger_price is not None or take_profit_trigger_price is not None:
             if reduce_only:
-                raise ValueError("stop loss is invalid for reduce-only orders")
-            body["attachAlgoOrds"] = [
-                {
-                    "slTriggerPx": _format_number(stop_loss_trigger_price),
-                    "slOrdPx": "-1",
-                }
-            ]
+                raise ValueError("attached TP/SL is invalid for reduce-only orders")
+            algo: dict[str, str] = {}
+            if stop_loss_trigger_price is not None:
+                algo["slTriggerPx"] = _format_number(stop_loss_trigger_price)
+                algo["slOrdPx"] = "-1"
+            if take_profit_trigger_price is not None:
+                algo["tpTriggerPx"] = _format_number(take_profit_trigger_price)
+                algo["tpOrdPx"] = "-1"
+            body["attachAlgoOrds"] = [algo]
         return body
 
     @staticmethod
@@ -455,7 +458,7 @@ class OkxClient:
             size=size,
         )
 
-    def submit_market_order_body(self, body: dict[str, str]) -> OrderResult:
+    def submit_market_order_body(self, body: dict[str, object]) -> OrderResult:
         required = {"instId", "side", "posSide", "ordType", "sz", "clOrdId"}
         if any(not body.get(key) for key in required) or body.get("ordType") != "market":
             raise ValueError("market order body is invalid")

+ 106 - 0
okx_codex_trader/research_metrics.py

@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import pandas as pd
+
+from okx_codex_trader.sampled_report import SegmentResult
+
+
+DEFAULT_INITIAL_EQUITY = 10_000.0
+DEFAULT_PRIMARY_COST = "maker_taker"
+DEFAULT_COSTS = (
+    ("maker_maker", 0.0012),
+    ("maker_taker", 0.0021),
+    ("taker_taker", 0.0030),
+)
+DEFAULT_HORIZONS = (
+    ("3y", pd.DateOffset(years=3)),
+    ("1y", pd.DateOffset(years=1)),
+    ("6m", pd.DateOffset(months=6)),
+    ("3m", pd.DateOffset(months=3)),
+    ("30d", pd.DateOffset(days=30)),
+)
+
+
+def format_utc_ts(ts: int) -> str:
+    return pd.to_datetime(ts, unit="ms", utc=True).strftime("%Y-%m-%d %H:%M")
+
+
+def cost_equity_frame(result: SegmentResult, cost: float, initial_equity: float = DEFAULT_INITIAL_EQUITY) -> pd.DataFrame:
+    rows = [{"ts": pd.to_datetime(result.equity_curve[0]["ts"], unit="ms", utc=True), "equity": initial_equity}]
+    equity = initial_equity
+    for trade in result.trades:
+        equity *= 1.0 + float(trade["return_pct"]) / 100.0 - cost * float(trade.get("cost_weight", 1.0))
+        rows.append({"ts": pd.to_datetime(str(trade["exit_time"]), utc=True), "equity": equity})
+    return pd.DataFrame(rows)
+
+
+def max_drawdown(values: list[float]) -> float:
+    peak = values[0]
+    dd = 0.0
+    for value in values:
+        peak = max(peak, value)
+        dd = max(dd, (peak - value) / peak if peak else 0.0)
+    return dd
+
+
+def equity_metrics(frame: pd.DataFrame, first_ts: int, last_ts: int) -> dict[str, float]:
+    years = (last_ts - first_ts) / 86_400_000 / 365
+    total_return = float(frame["equity"].iloc[-1] / frame["equity"].iloc[0] - 1.0)
+    annualized = (1.0 + total_return) ** (1.0 / years) - 1.0 if total_return > -1.0 and years > 0.0 else 0.0
+    dd = max_drawdown([float(value) for value in frame["equity"]])
+    return {
+        "net_total_return": total_return,
+        "net_annualized_return": annualized,
+        "net_max_drawdown": dd,
+        "net_calmar": annualized / dd if dd else 0.0,
+    }
+
+
+def horizon_rows(
+    frame: pd.DataFrame,
+    last_ts: int,
+    horizons: tuple[tuple[str, pd.DateOffset], ...] = DEFAULT_HORIZONS,
+) -> list[dict[str, object]]:
+    rows: list[dict[str, object]] = []
+    end_time = pd.to_datetime(last_ts, unit="ms", utc=True)
+    for label, offset in horizons:
+        cutoff = end_time - offset
+        before = frame[frame["ts"] <= cutoff]
+        if len(before):
+            start_equity = float(before["equity"].iloc[-1])
+            start_time = cutoff
+            after = frame[frame["ts"] > cutoff]
+            horizon_frame = pd.concat([pd.DataFrame([{"ts": cutoff, "equity": start_equity}]), after[["ts", "equity"]]], ignore_index=True)
+        else:
+            horizon_frame = frame[["ts", "equity"]].copy()
+            start_time = pd.Timestamp(horizon_frame["ts"].iloc[0])
+        rows.append(
+            {
+                "horizon": label,
+                "horizon_start": start_time.strftime("%Y-%m-%d %H:%M"),
+                "horizon_end": end_time.strftime("%Y-%m-%d %H:%M"),
+                **equity_metrics(horizon_frame, int(start_time.timestamp() * 1000), last_ts),
+            }
+        )
+    return rows
+
+
+def trade_stats(trades: list[dict[str, object]]) -> dict[str, float]:
+    if not trades:
+        return {"avg_return_pct": 0.0, "payoff_ratio": 0.0, "profit_factor": 0.0}
+    returns = [float(trade["return_pct"]) for trade in trades]
+    wins = [value for value in returns if value > 0.0]
+    losses = [-value for value in returns if value < 0.0]
+    return {
+        "avg_return_pct": sum(returns) / len(returns),
+        "payoff_ratio": (sum(wins) / len(wins)) / (sum(losses) / len(losses)) if wins and losses else 0.0,
+        "profit_factor": sum(wins) / sum(losses) if losses else 0.0,
+    }
+
+
+def worst_month(frame: pd.DataFrame) -> tuple[str, float]:
+    monthly = frame.set_index("ts")["equity"].resample("ME").last().ffill().pct_change().dropna()
+    if not len(monthly):
+        return "", 0.0
+    idx = monthly.idxmin()
+    return idx.strftime("%Y-%m"), float(monthly.loc[idx])

+ 72 - 0
okx_codex_trader/time_rules.py

@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+
+
+NY = ZoneInfo("America/New_York")
+BJ = ZoneInfo("Asia/Shanghai")
+
+
+def utc_timestamp(ts: int) -> pd.Timestamp:
+    return pd.to_datetime(ts, unit="ms", utc=True)
+
+
+def ny_minutes(ts: int) -> tuple[int, int]:
+    dt = utc_timestamp(ts).to_pydatetime().astimezone(NY)
+    return dt.weekday(), dt.hour * 60 + dt.minute
+
+
+def is_ny_weekend(ts: int) -> bool:
+    weekday, _ = ny_minutes(ts)
+    return weekday >= 5
+
+
+def is_us_open_window(ts: int) -> bool:
+    weekday, minute = ny_minutes(ts)
+    return weekday < 5 and 9 * 60 + 30 <= minute < 10 * 60 + 30
+
+
+def is_us_open_extended(ts: int) -> bool:
+    weekday, minute = ny_minutes(ts)
+    return weekday < 5 and 9 * 60 <= minute < 11 * 60
+
+
+def entry_allowed(ts: int, mode: str) -> bool:
+    weekday, minute = ny_minutes(ts)
+    if mode == "all":
+        return True
+    if mode == "weekday":
+        return not is_ny_weekend(ts)
+    if mode == "no_weekend":
+        return not is_ny_weekend(ts)
+    if mode == "weekend":
+        return is_ny_weekend(ts)
+    if mode == "no_us_open":
+        return not is_us_open_extended(ts)
+    if mode == "us_open_only":
+        return is_us_open_extended(ts)
+    if mode == "asia_bj":
+        hour = utc_timestamp(ts).to_pydatetime().astimezone(BJ).hour
+        return 8 <= hour < 16
+    if mode == "us_regular":
+        return weekday < 5 and 9 * 60 + 30 <= minute < 16 * 60
+    raise ValueError("entry_time_filter is invalid")
+
+
+def time_bucket(ts: int) -> str:
+    if is_ny_weekend(ts):
+        return "weekend"
+    _, minute = ny_minutes(ts)
+    if 4 * 60 <= minute < 9 * 60:
+        return "us_premarket"
+    if 9 * 60 <= minute < 9 * 60 + 30:
+        return "us_preopen"
+    if 9 * 60 + 30 <= minute < 10 * 60 + 30:
+        return "us_open_1h"
+    if 10 * 60 + 30 <= minute < 16 * 60:
+        return "us_regular_late"
+    if 16 * 60 <= minute < 20 * 60:
+        return "us_afterhours"
+    return "overnight"

+ 9 - 9
reports/eth-exploration/current-strategy-recent-activity-report.md

@@ -4,12 +4,12 @@ Scope: current live BB squeeze, selected T-gated BB squeeze observer, and crash-
 
 | strategy | window_days | total_return | trades | last_candle | reentry_entries_full | max_drawdown | profit_factor | win_rate |
 | --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| live_bb_squeeze_mxbuf0.0005 | 30 | 4.60% | 21 | 2026-05-19T07:45:00+00:00 |  |  |  |  |
-| live_bb_squeeze_mxbuf0.0005 | 14 | -4.04% | 11 | 2026-05-19T07:45:00+00:00 |  |  |  |  |
-| live_bb_squeeze_mxbuf0.0005 | 7 | 3.36% | 5 | 2026-05-19T07:45:00+00:00 |  |  |  |  |
-| bb_squeeze_t_gated_tre96 | 30 | -10.22% | 0 | 2026-05-19T07:45:00+00:00 | 32.0 |  |  |  |
-| bb_squeeze_t_gated_tre96 | 14 | -9.32% | 0 | 2026-05-19T07:45:00+00:00 | 32.0 |  |  |  |
-| bb_squeeze_t_gated_tre96 | 7 | -9.32% | 0 | 2026-05-19T07:45:00+00:00 | 32.0 |  |  |  |
-| crash_follow_short | 30 | 0.00% | 0 | 2026-05-19T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
-| crash_follow_short | 14 | 0.00% | 0 | 2026-05-19T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
-| crash_follow_short | 7 | 0.00% | 0 | 2026-05-19T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+| live_bb_squeeze_mxbuf0.0005 | 30 | -9.99% | 23 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| live_bb_squeeze_mxbuf0.0005 | 14 | -5.42% | 11 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| live_bb_squeeze_mxbuf0.0005 | 7 | -3.22% | 6 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| bb_squeeze_t_gated_tre96 | 30 | -10.22% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| bb_squeeze_t_gated_tre96 | 14 | -9.32% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| bb_squeeze_t_gated_tre96 | 7 | -9.32% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| crash_follow_short | 30 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+| crash_follow_short | 14 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+| crash_follow_short | 7 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |

+ 9 - 9
reports/eth-exploration/current-strategy-recent-activity.csv

@@ -1,10 +1,10 @@
 strategy,window_days,total_return,trades,last_candle,reentry_entries_full,max_drawdown,profit_factor,win_rate
-live_bb_squeeze_mxbuf0.0005,30,0.04599530586268963,21,2026-05-19T07:45:00+00:00,,,,
-live_bb_squeeze_mxbuf0.0005,14,-0.04043563931842753,11,2026-05-19T07:45:00+00:00,,,,
-live_bb_squeeze_mxbuf0.0005,7,0.03362544576375481,5,2026-05-19T07:45:00+00:00,,,,
-bb_squeeze_t_gated_tre96,30,-0.10223785436994537,0,2026-05-19T07:45:00+00:00,32.0,,,
-bb_squeeze_t_gated_tre96,14,-0.09324184616099995,0,2026-05-19T07:45:00+00:00,32.0,,,
-bb_squeeze_t_gated_tre96,7,-0.09324184616099995,0,2026-05-19T07:45:00+00:00,32.0,,,
-crash_follow_short,30,0.0,0,2026-05-19T00:00:00+00:00,,0.0,0.0,0.0
-crash_follow_short,14,0.0,0,2026-05-19T00:00:00+00:00,,0.0,0.0,0.0
-crash_follow_short,7,0.0,0,2026-05-19T00:00:00+00:00,,0.0,0.0,0.0
+live_bb_squeeze_mxbuf0.0005,30,-0.09986976641320788,23,2026-05-24T16:15:00+00:00,,,,
+live_bb_squeeze_mxbuf0.0005,14,-0.054237243595313345,11,2026-05-24T16:15:00+00:00,,,,
+live_bb_squeeze_mxbuf0.0005,7,-0.03223724653148252,6,2026-05-24T16:15:00+00:00,,,,
+bb_squeeze_t_gated_tre96,30,-0.10223785436994537,0,2026-05-24T16:15:00+00:00,32.0,,,
+bb_squeeze_t_gated_tre96,14,-0.09324184616099995,0,2026-05-24T16:15:00+00:00,32.0,,,
+bb_squeeze_t_gated_tre96,7,-0.09324184616099995,0,2026-05-24T16:15:00+00:00,32.0,,,
+crash_follow_short,30,0.0,0,2026-05-24T00:00:00+00:00,,0.0,0.0,0.0
+crash_follow_short,14,0.0,0,2026-05-24T00:00:00+00:00,,0.0,0.0,0.0
+crash_follow_short,7,0.0,0,2026-05-24T00:00:00+00:00,,0.0,0.0,0.0

+ 33 - 0
reports/live-recent/readonly-observers-snapshot.md

@@ -0,0 +1,33 @@
+# Read-only observer snapshot
+
+Generated at `2026-05-24T16:50:19.227337Z`.
+
+| observer | latest_event_utc | 24h | 3d | 7d | readout |
+| --- | --- | --- | --- | --- | --- |
+| live_bb_squeeze | 2026-05-24T16:45:03Z | hold:97 | hold:289 | hold:644, exit_middle:4, entry_short:2, entry_long:2 | live path active; recent 24h no entry |
+| bb_squeeze_t_gated | 2026-05-24T16:50:02Z | state_replay:1310, hold:96, error:1 | state_replay:3930, hold:288, error:1 | state_replay:7050, hold:516, error:4, entry_long:1, exit_stop:1 | has complementary entries: 1 in 7d |
+| calendar_fusion | 2026-05-24T16:47:00Z | error:283, flat:3 | error:855, flat:3 | error:1536, flat:465 | recent errors dominated; fixed services need fresh observation |
+| crash_follow_short | 2026-05-24T16:48:38Z | exit:256, hold:31 | hold:431, exit:429 | exit:772, hold:768, error:2 | healthy but no recent entries |
+| eth_focused_portfolio | 2026-05-24T16:47:53Z | exit_ETH-USDT-SWAP:229, flat:33, error:1 | exit_ETH-USDT-SWAP:625, flat:147, error:13, entry_ETH-USDT-SWAP:3 | exit_ETH-USDT-SWAP:1583, flat:226, error:26, entry_ETH-USDT-SWAP:3 | has complementary entries: 3 in 7d |
+| eth_nextgen_micro | 2026-05-24T16:45:34Z | observe_nextgen_no_signal:222, error:3 | observe_nextgen_no_signal:666, error:7, entry_long:3 | observe_nextgen_no_signal:1552, error:18, entry_long:3 | has complementary entries: 3 in 7d |
+| high_frequency_portfolio | 2026-05-24T16:49:49Z | flat:2 | flat:2 | flat:2 | healthy but no recent entries |
+| short_bias_readonly | 2026-05-24T16:45:25Z | hold:279 | hold:831, exit_short:5, entry_short:2 | hold:1947, exit_short:5, entry_short:2 | has complementary entries: 2 in 7d |
+
+## Latest server backtest
+
+# Current Strategy Recent Activity
+
+Scope: current live BB squeeze, selected T-gated BB squeeze observer, and crash-follow short observer.
+
+| strategy | window_days | total_return | trades | last_candle | reentry_entries_full | max_drawdown | profit_factor | win_rate |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| live_bb_squeeze_mxbuf0.0005 | 30 | -9.99% | 23 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| live_bb_squeeze_mxbuf0.0005 | 14 | -5.42% | 11 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| live_bb_squeeze_mxbuf0.0005 | 7 | -3.22% | 6 | 2026-05-24T16:15:00+00:00 |  |  |  |  |
+| bb_squeeze_t_gated_tre96 | 30 | -10.22% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| bb_squeeze_t_gated_tre96 | 14 | -9.32% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| bb_squeeze_t_gated_tre96 | 7 | -9.32% | 0 | 2026-05-24T16:15:00+00:00 | 32.0 |  |  |  |
+| crash_follow_short | 30 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+| crash_follow_short | 14 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+| crash_follow_short | 7 | 0.00% | 0 | 2026-05-24T00:00:00+00:00 |  | 0.00% | 0.000 | 0.00% |
+

+ 57 - 57
reports/long-short-fusion/calendar-fusion-observation-intent.json

@@ -1,15 +1,15 @@
 {
   "calendar_state": {
-    "decision_candle_time": "2026-05-10T13:00:00Z",
+    "decision_candle_time": "2026-05-24T15:00:00Z",
     "entry_signal_on_decision_candle": false,
-    "latest_local_candle_time": "2026-05-10T14:00:00Z",
-    "latest_signal_time": "2026-05-09T14:00:00Z",
+    "latest_local_candle_time": "2026-05-24T16:00:00Z",
+    "latest_signal_time": "2026-05-23T14:00:00Z",
     "side": "long",
     "spec": "eth-1h-long-h14-weekend-hold8-volcalm",
     "theoretical_calendar_position_active": false,
-    "theoretical_entry_time": "2026-05-09T15:00:00Z",
-    "theoretical_exit_time": "2026-05-09T23:00:00Z",
-    "vol_rank_on_decision_candle": 0.001388888888888889
+    "theoretical_entry_time": "2026-05-23T15:00:00Z",
+    "theoretical_exit_time": "2026-05-23T23:00:00Z",
+    "vol_rank_on_decision_candle": 0.975
   },
   "candidates": [
     {
@@ -19,96 +19,96 @@
         "btc_4h_vol_short_weight": 0.06,
         "btc_risk_short_weight": 0.08,
         "calendar_weight": 0.125,
-        "eth_4h_vol_short_gated_weight": 0.0,
-        "eth_4h_vol_short_weight": 0.1,
+        "eth_4h_vol_short_gated_weight": 0.12,
+        "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.18
       },
       "gross_exposure": 1.565,
       "label": "A",
       "metrics": {
-        "full_annualized_return": 0.3090309252509498,
-        "full_calmar": 3.7918761826044354,
-        "full_max_drawdown": 0.0814981582649392,
-        "full_total_return": 4.428773752018147,
-        "h1y_return": 0.2365389277076477,
-        "h3m_return": 0.0009841629362641,
-        "h3y_return": 0.9941600984614292,
-        "h6m_return": 0.089602405927327
+        "full_annualized_return": 0.3108540881090775,
+        "full_calmar": 3.667938411567919,
+        "full_max_drawdown": 0.0847489933660576,
+        "full_total_return": 4.513121828401145,
+        "h1y_return": 0.214011523635478,
+        "h3m_return": -0.0070688922741529,
+        "h3y_return": 0.98663848600643,
+        "h6m_return": 0.0835084654435625
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.10-btc4hs0.06-eg0.00-bg0.00-cal0.125",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.18-brs0.08-eth4hs0.00-btc4hs0.06-eg0.12-bg0.00-cal0.125",
+      "short_exposure": 0.26
     },
     {
       "calendar_weight": 0.125,
       "component_weights": {
-        "btc_4h_vol_short_gated_weight": 0.0,
-        "btc_4h_vol_short_weight": 0.04,
+        "btc_4h_vol_short_gated_weight": 0.06,
+        "btc_4h_vol_short_weight": 0.0,
         "btc_risk_short_weight": 0.08,
         "calendar_weight": 0.125,
         "eth_4h_vol_short_gated_weight": 0.12,
         "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.18
       },
       "gross_exposure": 1.565,
       "label": "B",
       "metrics": {
-        "full_annualized_return": 0.3108908662046894,
-        "full_calmar": 3.61199856746712,
-        "full_max_drawdown": 0.0860717025208287,
-        "full_total_return": 4.477413595369257,
-        "h1y_return": 0.2529775145570998,
-        "h3m_return": 0.0009841629362632,
-        "h3y_return": 1.003180018501102,
-        "h6m_return": 0.1034253292007991
+        "full_annualized_return": 0.3120162352930267,
+        "full_calmar": 3.492605128671096,
+        "full_max_drawdown": 0.0893362472418248,
+        "full_total_return": 4.544020411462094,
+        "h1y_return": 0.2156870049927428,
+        "h3m_return": -0.007068892274153,
+        "h3y_return": 0.9876525139808936,
+        "h6m_return": 0.0861688046125264
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.125",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.18-brs0.08-eth4hs0.00-btc4hs0.00-eg0.12-bg0.06-cal0.125",
+      "short_exposure": 0.26
     },
     {
-      "calendar_weight": 0.075,
+      "calendar_weight": 0.125,
       "component_weights": {
-        "btc_4h_vol_short_gated_weight": 0.0,
-        "btc_4h_vol_short_weight": 0.04,
-        "btc_risk_short_weight": 0.08,
-        "calendar_weight": 0.075,
+        "btc_4h_vol_short_gated_weight": 0.06,
+        "btc_4h_vol_short_weight": 0.0,
+        "btc_risk_short_weight": 0.12,
+        "calendar_weight": 0.125,
         "eth_4h_vol_short_gated_weight": 0.12,
         "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.15
       },
-      "gross_exposure": 1.515,
+      "gross_exposure": 1.575,
       "label": "C",
       "metrics": {
-        "full_annualized_return": 0.3053863128633511,
-        "full_calmar": 3.57071173636062,
-        "full_max_drawdown": 0.0855253337180923,
-        "full_total_return": 4.334514973437172,
-        "h1y_return": 0.250357201073762,
-        "h3m_return": 0.0007220393014699,
-        "h3y_return": 0.9952229035084073,
-        "h6m_return": 0.1005910014631774
+        "full_annualized_return": 0.3116056147405077,
+        "full_calmar": 3.0488394627481585,
+        "full_max_drawdown": 0.1022046646101966,
+        "full_total_return": 4.533086435719239,
+        "h1y_return": 0.2351745349226974,
+        "h3m_return": -0.0062179812925324,
+        "h3y_return": 1.0052856165560038,
+        "h6m_return": 0.0999397894135472
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.075",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.15-brs0.12-eth4hs0.00-btc4hs0.00-eg0.12-bg0.06-cal0.125",
+      "short_exposure": 0.3
     }
   ],
   "candles": {
     "BTC-USDT-SWAP": {
       "first_ts": 1576476000000,
-      "last_time": "2026-05-10T14:45:00Z",
-      "last_ts": 1778424300000,
-      "rows": 224388
+      "last_time": "2026-05-24T16:30:00Z",
+      "last_ts": 1779640200000,
+      "rows": 225739
     },
     "ETH-USDT-SWAP": {
       "first_ts": 1577232000000,
-      "last_time": "2026-05-10T14:45:00Z",
-      "last_ts": 1778424300000,
-      "rows": 223548
+      "last_time": "2026-05-24T16:30:00Z",
+      "last_ts": 1779640200000,
+      "rows": 224899
     }
   },
-  "created_at": "2026-05-10T15:23:48Z",
+  "created_at": "2026-05-24T16:47:00Z",
   "mode": "readonly_observation_intent",
   "orders_submitted": 0,
   "private_key_required": false,
@@ -118,7 +118,7 @@
     "no_order_submission": true,
     "reason": "candidate requires read-only observation before any live promotion"
   },
-  "source_report": "reports/long-short-fusion/calendar-fusion-observation-candidates.md",
+  "source_report": "reports/long-short-fusion/fusion-calendar-total.csv",
   "strategy_family": "calendar_fusion_observation",
   "submitted_orders": 0
 }

+ 65 - 65
reports/long-short-fusion/calendar-fusion-observation-intent.md

@@ -5,37 +5,37 @@ Read-only observation intent. No order or cancel request was submitted.
 ## Calendar Leg
 
 - Spec: `eth-1h-long-h14-weekend-hold8-volcalm`
-- Decision candle: `2026-05-10T13:00:00Z`
-- Latest local candle: `2026-05-10T14:00:00Z`
+- Decision candle: `2026-05-24T15:00:00Z`
+- Latest local candle: `2026-05-24T16:00:00Z`
 - Entry signal on decision candle: `False`
 - Theoretical active position: `False`
-- Latest signal time: `2026-05-09T14:00:00Z`
-- Theoretical entry: `2026-05-09T15:00:00Z`
-- Theoretical exit: `2026-05-09T23:00:00Z`
+- Latest signal time: `2026-05-23T14:00:00Z`
+- Theoretical entry: `2026-05-23T15:00:00Z`
+- Theoretical exit: `2026-05-23T23:00:00Z`
 
 ## Candidates
 
 | label | calendar_weight | gross_exposure | short_exposure | full_return | max_dd | calmar |
 | --- | ---: | ---: | ---: | ---: | ---: | ---: |
-| A | 0.125 | 1.565 | 0.240 | 442.88% | 8.15% | 3.79 |
-| B | 0.125 | 1.565 | 0.240 | 447.74% | 8.61% | 3.61 |
-| C | 0.075 | 1.515 | 0.240 | 433.45% | 8.55% | 3.57 |
+| A | 0.125 | 1.565 | 0.260 | 451.31% | 8.47% | 3.67 |
+| B | 0.125 | 1.565 | 0.260 | 454.40% | 8.93% | 3.49 |
+| C | 0.125 | 1.575 | 0.300 | 453.31% | 10.22% | 3.05 |
 
 ## Intent JSON
 
 ```json
 {
   "calendar_state": {
-    "decision_candle_time": "2026-05-10T13:00:00Z",
+    "decision_candle_time": "2026-05-24T15:00:00Z",
     "entry_signal_on_decision_candle": false,
-    "latest_local_candle_time": "2026-05-10T14:00:00Z",
-    "latest_signal_time": "2026-05-09T14:00:00Z",
+    "latest_local_candle_time": "2026-05-24T16:00:00Z",
+    "latest_signal_time": "2026-05-23T14:00:00Z",
     "side": "long",
     "spec": "eth-1h-long-h14-weekend-hold8-volcalm",
     "theoretical_calendar_position_active": false,
-    "theoretical_entry_time": "2026-05-09T15:00:00Z",
-    "theoretical_exit_time": "2026-05-09T23:00:00Z",
-    "vol_rank_on_decision_candle": 0.001388888888888889
+    "theoretical_entry_time": "2026-05-23T15:00:00Z",
+    "theoretical_exit_time": "2026-05-23T23:00:00Z",
+    "vol_rank_on_decision_candle": 0.975
   },
   "candidates": [
     {
@@ -45,96 +45,96 @@ Read-only observation intent. No order or cancel request was submitted.
         "btc_4h_vol_short_weight": 0.06,
         "btc_risk_short_weight": 0.08,
         "calendar_weight": 0.125,
-        "eth_4h_vol_short_gated_weight": 0.0,
-        "eth_4h_vol_short_weight": 0.1,
+        "eth_4h_vol_short_gated_weight": 0.12,
+        "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.18
       },
       "gross_exposure": 1.565,
       "label": "A",
       "metrics": {
-        "full_annualized_return": 0.3090309252509498,
-        "full_calmar": 3.7918761826044354,
-        "full_max_drawdown": 0.0814981582649392,
-        "full_total_return": 4.428773752018147,
-        "h1y_return": 0.2365389277076477,
-        "h3m_return": 0.0009841629362641,
-        "h3y_return": 0.9941600984614292,
-        "h6m_return": 0.089602405927327
+        "full_annualized_return": 0.3108540881090775,
+        "full_calmar": 3.667938411567919,
+        "full_max_drawdown": 0.0847489933660576,
+        "full_total_return": 4.513121828401145,
+        "h1y_return": 0.214011523635478,
+        "h3m_return": -0.0070688922741529,
+        "h3y_return": 0.98663848600643,
+        "h6m_return": 0.0835084654435625
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.10-btc4hs0.06-eg0.00-bg0.00-cal0.125",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.18-brs0.08-eth4hs0.00-btc4hs0.06-eg0.12-bg0.00-cal0.125",
+      "short_exposure": 0.26
     },
     {
       "calendar_weight": 0.125,
       "component_weights": {
-        "btc_4h_vol_short_gated_weight": 0.0,
-        "btc_4h_vol_short_weight": 0.04,
+        "btc_4h_vol_short_gated_weight": 0.06,
+        "btc_4h_vol_short_weight": 0.0,
         "btc_risk_short_weight": 0.08,
         "calendar_weight": 0.125,
         "eth_4h_vol_short_gated_weight": 0.12,
         "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.18
       },
       "gross_exposure": 1.565,
       "label": "B",
       "metrics": {
-        "full_annualized_return": 0.3108908662046894,
-        "full_calmar": 3.61199856746712,
-        "full_max_drawdown": 0.0860717025208287,
-        "full_total_return": 4.477413595369257,
-        "h1y_return": 0.2529775145570998,
-        "h3m_return": 0.0009841629362632,
-        "h3y_return": 1.003180018501102,
-        "h6m_return": 0.1034253292007991
+        "full_annualized_return": 0.3120162352930267,
+        "full_calmar": 3.492605128671096,
+        "full_max_drawdown": 0.0893362472418248,
+        "full_total_return": 4.544020411462094,
+        "h1y_return": 0.2156870049927428,
+        "h3m_return": -0.007068892274153,
+        "h3y_return": 0.9876525139808936,
+        "h6m_return": 0.0861688046125264
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.125",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.18-brs0.08-eth4hs0.00-btc4hs0.00-eg0.12-bg0.06-cal0.125",
+      "short_exposure": 0.26
     },
     {
-      "calendar_weight": 0.075,
+      "calendar_weight": 0.125,
       "component_weights": {
-        "btc_4h_vol_short_gated_weight": 0.0,
-        "btc_4h_vol_short_weight": 0.04,
-        "btc_risk_short_weight": 0.08,
-        "calendar_weight": 0.075,
+        "btc_4h_vol_short_gated_weight": 0.06,
+        "btc_4h_vol_short_weight": 0.0,
+        "btc_risk_short_weight": 0.12,
+        "calendar_weight": 0.125,
         "eth_4h_vol_short_gated_weight": 0.12,
         "eth_4h_vol_short_weight": 0.0,
         "long_variant": "long_rotation_riskoff00",
-        "long_weight": 1.2
+        "long_weight": 1.15
       },
-      "gross_exposure": 1.515,
+      "gross_exposure": 1.575,
       "label": "C",
       "metrics": {
-        "full_annualized_return": 0.3053863128633511,
-        "full_calmar": 3.57071173636062,
-        "full_max_drawdown": 0.0855253337180923,
-        "full_total_return": 4.334514973437172,
-        "h1y_return": 0.250357201073762,
-        "h3m_return": 0.0007220393014699,
-        "h3y_return": 0.9952229035084073,
-        "h6m_return": 0.1005910014631774
+        "full_annualized_return": 0.3116056147405077,
+        "full_calmar": 3.0488394627481585,
+        "full_max_drawdown": 0.1022046646101966,
+        "full_total_return": 4.533086435719239,
+        "h1y_return": 0.2351745349226974,
+        "h3m_return": -0.0062179812925324,
+        "h3y_return": 1.0052856165560038,
+        "h6m_return": 0.0999397894135472
       },
-      "name": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.075",
-      "short_exposure": 0.24
+      "name": "fusion-cal-lr_riskoff00-l1.15-brs0.12-eth4hs0.00-btc4hs0.00-eg0.12-bg0.06-cal0.125",
+      "short_exposure": 0.3
     }
   ],
   "candles": {
     "BTC-USDT-SWAP": {
       "first_ts": 1576476000000,
-      "last_time": "2026-05-10T14:45:00Z",
-      "last_ts": 1778424300000,
-      "rows": 224388
+      "last_time": "2026-05-24T16:30:00Z",
+      "last_ts": 1779640200000,
+      "rows": 225739
     },
     "ETH-USDT-SWAP": {
       "first_ts": 1577232000000,
-      "last_time": "2026-05-10T14:45:00Z",
-      "last_ts": 1778424300000,
-      "rows": 223548
+      "last_time": "2026-05-24T16:30:00Z",
+      "last_ts": 1779640200000,
+      "rows": 224899
     }
   },
-  "created_at": "2026-05-10T15:23:48Z",
+  "created_at": "2026-05-24T16:47:00Z",
   "mode": "readonly_observation_intent",
   "orders_submitted": 0,
   "private_key_required": false,
@@ -144,7 +144,7 @@ Read-only observation intent. No order or cancel request was submitted.
     "no_order_submission": true,
     "reason": "candidate requires read-only observation before any live promotion"
   },
-  "source_report": "reports/long-short-fusion/calendar-fusion-observation-candidates.md",
+  "source_report": "reports/long-short-fusion/fusion-calendar-total.csv",
   "strategy_family": "calendar_fusion_observation",
   "submitted_orders": 0
 }

+ 95 - 0
reports/ultrashort/high-frequency-portfolio-observation-intent.json

@@ -0,0 +1,95 @@
+{
+  "created_at": "2026-05-24T16:49:49Z",
+  "legs": [
+    {
+      "bar": "15m",
+      "family": "rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 75538.6,
+        "side": "short",
+        "time": "2026-05-23T03:00:00Z"
+      },
+      "latest_exit": {
+        "price": 75545.1,
+        "side": "short",
+        "time": "2026-05-23T04:30:00Z"
+      },
+      "leg": "BTC-USDT-SWAP:rsi:15m:rsi2-both-t30-l8.0-s92.0-x50.0",
+      "open_position": null,
+      "symbol": "BTC-USDT-SWAP",
+      "trade_count": 3065
+    },
+    {
+      "bar": "15m",
+      "family": "nextgen_btc_trend_eth_rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 2130.69,
+        "side": "long",
+        "time": "2026-05-21T23:00:00Z"
+      },
+      "latest_exit": {
+        "price": 2132.11,
+        "side": "long",
+        "time": "2026-05-21T23:30:00Z"
+      },
+      "leg": "ETH-USDT-SWAP:nextgen_btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
+      "open_position": null,
+      "symbol": "ETH-USDT-SWAP",
+      "trade_count": 287
+    },
+    {
+      "bar": "15m",
+      "family": "nextgen_btc_shock_guard_eth_rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 2361.24,
+        "side": "long",
+        "time": "2026-05-04T06:30:00Z"
+      },
+      "latest_exit": {
+        "price": 2367.58,
+        "side": "long",
+        "time": "2026-05-04T07:00:00Z"
+      },
+      "leg": "ETH-USDT-SWAP:nextgen_btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05",
+      "open_position": null,
+      "symbol": "ETH-USDT-SWAP",
+      "trade_count": 245
+    }
+  ],
+  "mode": "readonly_observation_intent",
+  "portfolio": "risk-3-hf00486",
+  "portfolio_metrics": {
+    "annualized_return": 0.1277589853319887,
+    "calmar": 1.213156769475615,
+    "max_drawdown": 0.1053111918810067,
+    "ret_1y": 0.2293575871571369,
+    "ret_3m": 0.0738744932387169,
+    "ret_6m": 0.0278431582284719,
+    "total_return": 1.1416618180241973,
+    "trades_per_month": 46.43298551038063
+  },
+  "private_key_required": false,
+  "risk_limits": {
+    "blocked_for_live_trading": true,
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "reason": "high-frequency portfolio requires read-only observation before any live promotion"
+  },
+  "source_report": "reports/ultrashort/high-frequency-portfolio-report.md",
+  "strategy_family": "cross_symbol_high_frequency_portfolio",
+  "submitted_orders": 0,
+  "target": {
+    "active_legs": [],
+    "long_unit": 0,
+    "net_unit": 0,
+    "short_unit": 0
+  },
+  "weights": {
+    "BTC-USDT-SWAP:rsi:15m:rsi2-both-t30-l8.0-s92.0-x50.0": 0.05370345233958788,
+    "ETH-USDT-SWAP:nextgen_btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05": 0.49448598160586454,
+    "ETH-USDT-SWAP:nextgen_btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0": 0.4518105660545476
+  }
+}

+ 114 - 0
reports/ultrashort/high-frequency-portfolio-observation-intent.md

@@ -0,0 +1,114 @@
+# High-frequency Portfolio Observation Intent
+
+Read-only observation intent. No order or cancel request was submitted.
+
+- Portfolio: `risk-3-hf00486`
+- Net unit: `0.000000`
+- Long unit: `0.000000`
+- Short unit: `0.000000`
+
+| leg | weight | open_side | latest_candle | latest_entry | latest_exit |
+| --- | ---: | --- | --- | --- | --- |
+| `BTC-USDT-SWAP:rsi:15m:rsi2-both-t30-l8.0-s92.0-x50.0` | 0.053703 | flat | 2026-05-24T16:30:00Z | 2026-05-23T03:00:00Z | 2026-05-23T04:30:00Z |
+| `ETH-USDT-SWAP:nextgen_btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0` | 0.451811 | flat | 2026-05-24T16:30:00Z | 2026-05-21T23:00:00Z | 2026-05-21T23:30:00Z |
+| `ETH-USDT-SWAP:nextgen_btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05` | 0.494486 | flat | 2026-05-24T16:30:00Z | 2026-05-04T06:30:00Z | 2026-05-04T07:00:00Z |
+
+## Intent JSON
+
+```json
+{
+  "created_at": "2026-05-24T16:49:49Z",
+  "legs": [
+    {
+      "bar": "15m",
+      "family": "rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 75538.6,
+        "side": "short",
+        "time": "2026-05-23T03:00:00Z"
+      },
+      "latest_exit": {
+        "price": 75545.1,
+        "side": "short",
+        "time": "2026-05-23T04:30:00Z"
+      },
+      "leg": "BTC-USDT-SWAP:rsi:15m:rsi2-both-t30-l8.0-s92.0-x50.0",
+      "open_position": null,
+      "symbol": "BTC-USDT-SWAP",
+      "trade_count": 3065
+    },
+    {
+      "bar": "15m",
+      "family": "nextgen_btc_trend_eth_rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 2130.69,
+        "side": "long",
+        "time": "2026-05-21T23:00:00Z"
+      },
+      "latest_exit": {
+        "price": 2132.11,
+        "side": "long",
+        "time": "2026-05-21T23:30:00Z"
+      },
+      "leg": "ETH-USDT-SWAP:nextgen_btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0",
+      "open_position": null,
+      "symbol": "ETH-USDT-SWAP",
+      "trade_count": 287
+    },
+    {
+      "bar": "15m",
+      "family": "nextgen_btc_shock_guard_eth_rsi",
+      "latest_candle_time": "2026-05-24T16:30:00Z",
+      "latest_entry": {
+        "price": 2361.24,
+        "side": "long",
+        "time": "2026-05-04T06:30:00Z"
+      },
+      "latest_exit": {
+        "price": 2367.58,
+        "side": "long",
+        "time": "2026-05-04T07:00:00Z"
+      },
+      "leg": "ETH-USDT-SWAP:nextgen_btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05",
+      "open_position": null,
+      "symbol": "ETH-USDT-SWAP",
+      "trade_count": 245
+    }
+  ],
+  "mode": "readonly_observation_intent",
+  "portfolio": "risk-3-hf00486",
+  "portfolio_metrics": {
+    "annualized_return": 0.1277589853319887,
+    "calmar": 1.213156769475615,
+    "max_drawdown": 0.1053111918810067,
+    "ret_1y": 0.2293575871571369,
+    "ret_3m": 0.0738744932387169,
+    "ret_6m": 0.0278431582284719,
+    "total_return": 1.1416618180241973,
+    "trades_per_month": 46.43298551038063
+  },
+  "private_key_required": false,
+  "risk_limits": {
+    "blocked_for_live_trading": true,
+    "no_cancel_submission": true,
+    "no_order_submission": true,
+    "reason": "high-frequency portfolio requires read-only observation before any live promotion"
+  },
+  "source_report": "reports/ultrashort/high-frequency-portfolio-report.md",
+  "strategy_family": "cross_symbol_high_frequency_portfolio",
+  "submitted_orders": 0,
+  "target": {
+    "active_legs": [],
+    "long_unit": 0,
+    "net_unit": 0,
+    "short_unit": 0
+  },
+  "weights": {
+    "BTC-USDT-SWAP:rsi:15m:rsi2-both-t30-l8.0-s92.0-x50.0": 0.05370345233958788,
+    "ETH-USDT-SWAP:nextgen_btc_shock_guard_eth_rsi:15m:eth-btc-shock-filter-et50-l3.0-x55.0-bt480-bm240-br0.01-sw96-sv0.01-sd0.05": 0.49448598160586454,
+    "ETH-USDT-SWAP:nextgen_btc_trend_eth_rsi:15m:eth-btc-rsi-filter-et50-l3.0-x55.0-bt480-bm240-br0.0": 0.4518105660545476
+  }
+}
+```

+ 13 - 11
scripts/build_calendar_fusion_observation_intent.py

@@ -13,15 +13,10 @@ from scripts import search_eth_btc_calendar_carry as carry
 
 
 REPORT_DIR = Path("reports/long-short-fusion")
-SELECTED_PATH = REPORT_DIR / "fusion-calendar-selected.csv"
+TOTAL_PATH = REPORT_DIR / "fusion-calendar-total.csv"
 OUTPUT_JSON = REPORT_DIR / "calendar-fusion-observation-intent.json"
 OUTPUT_MD = REPORT_DIR / "calendar-fusion-observation-intent.md"
 CANDIDATE_SPEC = carry.Spec("ETH-USDT-SWAP", "1h", "long", 14, "weekend", 8, "calm")
-CANDIDATES = {
-    "A": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.10-btc4hs0.06-eg0.00-bg0.00-cal0.125",
-    "B": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.125",
-    "C": "fusion-cal-lr_riskoff00-l1.20-brs0.08-eth4hs0.00-btc4hs0.04-eg0.12-bg0.00-cal0.075",
-}
 BAR_DELTAS = {"1h": pd.Timedelta(hours=1), "4h": pd.Timedelta(hours=4)}
 
 
@@ -70,14 +65,21 @@ def calendar_state() -> dict[str, object]:
 
 
 def selected_candidates() -> list[dict[str, object]]:
-    selected = pd.read_csv(SELECTED_PATH).set_index("name")
+    selected = pd.read_csv(TOTAL_PATH)
+    selected = selected[
+        (selected["calendar_weight"] <= 0.125)
+        & (selected["full_improved_vs_no_calendar"])
+        & (selected["6m_improved_vs_no_calendar"])
+        & (selected["3m_improved_vs_no_calendar"])
+    ].sort_values(["score", "calmar", "total_return"], ascending=False).head(3)
+    if selected.empty:
+        raise ValueError("no calendar fusion observation candidates")
     rows = []
-    for label, name in CANDIDATES.items():
-        row = selected.loc[name]
+    for label, (_, row) in zip(["A", "B", "C"], selected.iterrows(), strict=True):
         rows.append(
             {
                 "label": label,
-                "name": name,
+                "name": str(row["name"]),
                 "calendar_weight": float(row["calendar_weight"]),
                 "gross_exposure": float(row["gross_exposure"]),
                 "short_exposure": float(row["short_exposure"]),
@@ -113,7 +115,7 @@ def build_payload() -> dict[str, object]:
         "submitted_orders": 0,
         "private_key_required": False,
         "strategy_family": "calendar_fusion_observation",
-        "source_report": "reports/long-short-fusion/calendar-fusion-observation-candidates.md",
+        "source_report": "reports/long-short-fusion/fusion-calendar-total.csv",
         "risk_limits": {
             "no_order_submission": True,
             "no_cancel_submission": True,

+ 152 - 0
scripts/build_high_frequency_portfolio_observation_intent.py

@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+import json
+import sys
+from datetime import UTC, datetime
+from pathlib import Path
+
+import pandas as pd
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from scripts import explore_ultrashort as explore
+from scripts import search_cross_symbol_high_frequency_portfolios as hf
+
+
+REPORT_DIR = Path("reports/ultrashort")
+OUTPUT_JSON = REPORT_DIR / "high-frequency-portfolio-observation-intent.json"
+OUTPUT_MD = REPORT_DIR / "high-frequency-portfolio-observation-intent.md"
+PORTFOLIO = "risk-3-hf00486"
+QUALIFIED_PATH = REPORT_DIR / "high-frequency-portfolio-qualified.csv"
+YEARS = 10.0
+
+
+def iso_from_ms(ts: int) -> str:
+    return datetime.fromtimestamp(ts / 1000, UTC).isoformat().replace("+00:00", "Z")
+
+
+def load_portfolio() -> tuple[list[str], dict[str, float], dict[str, object]]:
+    row = pd.read_csv(QUALIFIED_PATH).set_index("portfolio").loc[PORTFOLIO]
+    weights = {str(key): float(value) for key, value in json.loads(str(row["weights_json"])).items()}
+    return str(row["legs"]).split(";"), weights, row.to_dict()
+
+
+def run_leg(leg: hf.LegSpec) -> dict[str, object]:
+    data = {}
+    for symbol in ("BTC-USDT-SWAP", "ETH-USDT-SWAP") if leg.pair else (leg.symbol,):
+        data[(symbol, leg.bar)] = hf.load_candles(symbol, leg.bar, YEARS)
+    result = leg.run(data)
+    latest_candle = result.candles[-1]
+    latest_entry = result.entries[-1] if result.entries else None
+    latest_exit = result.exits[-1] if result.exits else None
+    position = result.open_position
+    return {
+        "leg": leg.key,
+        "symbol": leg.symbol,
+        "family": leg.family,
+        "bar": leg.bar,
+        "latest_candle_time": iso_from_ms(latest_candle.ts),
+        "trade_count": result.trade_count,
+        "open_position": None
+        if position is None
+        else {
+            "side": str(position["side"]),
+            "entry_time": iso_from_ms(int(position["entry_time"])),
+            "entry_price": float(position["entry_price"]),
+        },
+        "latest_entry": None
+        if latest_entry is None
+        else {
+            "time": iso_from_ms(int(latest_entry["ts"])),
+            "side": str(latest_entry["side"]),
+            "price": float(latest_entry["price"]),
+        },
+        "latest_exit": None
+        if latest_exit is None
+        else {
+            "time": iso_from_ms(int(latest_exit["ts"])),
+            "side": str(latest_exit["side"]),
+            "price": float(latest_exit["price"]),
+        },
+    }
+
+
+def build_payload() -> dict[str, object]:
+    leg_names, weights, metrics = load_portfolio()
+    specs = {leg.key: leg for leg in hf.build_legs()}
+    legs = [run_leg(specs[name]) for name in leg_names]
+    active = [leg for leg in legs if leg["open_position"] is not None]
+    long_unit = sum(weights[str(leg["leg"])] for leg in active if leg["open_position"]["side"] == "long")
+    short_unit = sum(weights[str(leg["leg"])] for leg in active if leg["open_position"]["side"] == "short")
+    return {
+        "mode": "readonly_observation_intent",
+        "created_at": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
+        "submitted_orders": 0,
+        "private_key_required": False,
+        "strategy_family": "cross_symbol_high_frequency_portfolio",
+        "portfolio": PORTFOLIO,
+        "source_report": "reports/ultrashort/high-frequency-portfolio-report.md",
+        "risk_limits": {
+            "no_order_submission": True,
+            "no_cancel_submission": True,
+            "blocked_for_live_trading": True,
+            "reason": "high-frequency portfolio requires read-only observation before any live promotion",
+        },
+        "portfolio_metrics": {
+            "total_return": float(metrics["total_return"]),
+            "annualized_return": float(metrics["annualized_return"]),
+            "max_drawdown": float(metrics["max_drawdown"]),
+            "calmar": float(metrics["calmar"]),
+            "trades_per_month": float(metrics["trades_per_month"]),
+            "ret_1y": float(metrics["ret_1y"]),
+            "ret_6m": float(metrics["ret_6m"]),
+            "ret_3m": float(metrics["ret_3m"]),
+        },
+        "weights": weights,
+        "legs": legs,
+        "target": {
+            "long_unit": long_unit,
+            "short_unit": short_unit,
+            "net_unit": long_unit - short_unit,
+            "active_legs": [str(leg["leg"]) for leg in active],
+        },
+    }
+
+
+def markdown(payload: dict[str, object]) -> str:
+    lines = [
+        "# High-frequency Portfolio Observation Intent",
+        "",
+        "Read-only observation intent. No order or cancel request was submitted.",
+        "",
+        f"- Portfolio: `{payload['portfolio']}`",
+        f"- Net unit: `{payload['target']['net_unit']:.6f}`",
+        f"- Long unit: `{payload['target']['long_unit']:.6f}`",
+        f"- Short unit: `{payload['target']['short_unit']:.6f}`",
+        "",
+        "| leg | weight | open_side | latest_candle | latest_entry | latest_exit |",
+        "| --- | ---: | --- | --- | --- | --- |",
+    ]
+    weights = payload["weights"]
+    for leg in payload["legs"]:
+        position = leg["open_position"]
+        lines.append(
+            f"| `{leg['leg']}` | {weights[leg['leg']]:.6f} | {position['side'] if position else 'flat'} | "
+            f"{leg['latest_candle_time']} | {leg['latest_entry']['time'] if leg['latest_entry'] else ''} | "
+            f"{leg['latest_exit']['time'] if leg['latest_exit'] else ''} |"
+        )
+    lines.extend(["", "## Intent JSON", "", "```json", json.dumps(payload, indent=2, sort_keys=True), "```", ""])
+    return "\n".join(lines)
+
+
+def main() -> int:
+    payload = build_payload()
+    REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    OUTPUT_JSON.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    OUTPUT_MD.write_text(markdown(payload), encoding="utf-8")
+    print(json.dumps(payload, indent=2, sort_keys=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 2 - 1
scripts/explore_ultrashort.py

@@ -836,10 +836,11 @@ def run_rsi2_long_guarded_price_twap_segment(
     for index in range(warmup_bars, len(candles)):
         candle = candles[index]
         if pending_exit and position is not None:
-            equity, won = _trade(
+            equity, won = _close_partial_trade(
                 trades=trades,
                 exits=exits,
                 position=position,
+                account_equity=equity,
                 candle=candle,
                 exit_price=candle.open,
                 leverage=leverage,

+ 76 - 124
scripts/run_bb_squeeze_executor.py

@@ -5,7 +5,7 @@ import json
 import os
 import sys
 import time
-from dataclasses import asdict, dataclass
+from dataclasses import asdict
 from datetime import UTC, datetime
 from pathlib import Path
 
@@ -14,6 +14,17 @@ import pandas as pd
 sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
 
 from okx_codex_trader.config import load_config
+from okx_codex_trader.candles import align_candles_by_ts
+from okx_codex_trader.bb_squeeze_strategy import (
+    BANDWIDTH_LOOKBACK,
+    COOLDOWN_BARS,
+    EMPTY_STATE,
+    STOP_LOSS_PCT,
+    TAKE_PROFIT_PCT,
+    StrategyState,
+    signal_from_frame,
+    strategy_name,
+)
 from okx_codex_trader.live_execution import (
     TargetPosition,
     current_position_from_okx,
@@ -29,32 +40,14 @@ STATE_DIR = ROOT / "var" / "bb-squeeze-executor"
 STATE_FILE = "runtime-state.json"
 EVENTS_FILE = "events.jsonl"
 SYMBOL = "ETH-USDT-SWAP"
+BTC_SYMBOL = "BTC-USDT-SWAP"
 BAR = "15m"
 LEVERAGE = 3
 
-BAND_LENGTH = 48
-BANDWIDTH_LOOKBACK = 960
-BANDWIDTH_QUANTILE = 0.25
-STOP_LOSS_PCT = 0.01
-MIDDLE_EXIT_BUFFER_PCT = 0.0005
-ETH_VOL_CAP = 0.006
-COOLDOWN_BARS = 24
 LIVE_CANDLE_LIMIT = 1_200
 RECENT_CANDLE_LIMIT = 20
 
 
-@dataclass(frozen=True)
-class StrategyState:
-    last_candle_ts: int | None
-    active_side: str | None
-    entry_price: float | None
-    entry_candle_ts: int | None
-    cooldown_until_ts: int | None
-
-
-EMPTY_STATE = StrategyState(None, None, None, None, None)
-
-
 def now_iso() -> str:
     return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
 
@@ -69,6 +62,8 @@ def load_state(path: Path) -> StrategyState:
         entry_price=payload["entry_price"],
         entry_candle_ts=payload["entry_candle_ts"],
         cooldown_until_ts=payload["cooldown_until_ts"],
+        middle_exit_streak=int(payload.get("middle_exit_streak", 0)),
+        max_favorable_move_pct=payload.get("max_favorable_move_pct"),
     )
 
 
@@ -83,113 +78,49 @@ def append_event(state_dir: Path, payload: dict[str, object]) -> None:
         handle.write(json.dumps(payload, sort_keys=True) + "\n")
 
 
+def append_loop_error(state_dir: Path, message: str) -> None:
+    append_event(
+        state_dir,
+        {
+            "created_at": now_iso(),
+            "mode": "bb_squeeze_live_executor_error",
+            "execution_error": message,
+        },
+    )
+
+
 def frame_from_candles(candles: list[Candle]) -> pd.DataFrame:
     frame = pd.DataFrame([asdict(candle) for candle in candles])
     frame["time"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
     return frame.sort_values("ts").drop_duplicates("ts", keep="last").reset_index(drop=True)
 
 
+def aligned_frame_from_candles(eth_candles: list[Candle], btc_candles: list[Candle]) -> pd.DataFrame:
+    eth_candles, btc_candles = align_candles_by_ts(eth_candles, btc_candles)
+    eth = frame_from_candles(eth_candles)
+    btc = frame_from_candles(btc_candles)[["ts", "close"]].rename(columns={"close": "btc_close"})
+    return eth.merge(btc, on="ts", how="inner").sort_values("ts").reset_index(drop=True)
+
+
 def load_live_frame(client: OkxClient) -> pd.DataFrame:
-    return frame_from_candles(client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT))
+    return aligned_frame_from_candles(
+        client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT),
+        client.get_candles(BTC_SYMBOL, BAR, LIVE_CANDLE_LIMIT),
+    )
 
 
 def refresh_live_frame(client: OkxClient, frame: pd.DataFrame | None) -> pd.DataFrame:
     if frame is None or len(frame) < BANDWIDTH_LOOKBACK + 2:
         return load_live_frame(client)
-    recent = frame_from_candles(client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT))
+    recent = aligned_frame_from_candles(
+        client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT),
+        client.get_recent_candles(BTC_SYMBOL, BAR, RECENT_CANDLE_LIMIT),
+    )
     merged = pd.concat([frame, recent], ignore_index=True)
     merged = merged.sort_values("ts").drop_duplicates("ts", keep="last").tail(LIVE_CANDLE_LIMIT)
     return merged.reset_index(drop=True)
 
 
-def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
-    if len(frame) < BANDWIDTH_LOOKBACK + 2:
-        raise ValueError("not enough candles")
-    close = frame["close"].astype(float)
-    middle = close.rolling(BAND_LENGTH).mean()
-    stdev = close.rolling(BAND_LENGTH).std(ddof=0)
-    upper = middle + (2.0 * stdev)
-    lower = middle - (2.0 * stdev)
-    bandwidth = (upper - lower) / middle
-    threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
-    eth_vol = close.pct_change().rolling(96).std(ddof=0)
-    decision_index = len(frame) - 1
-    row = frame.iloc[decision_index]
-    candle_ts = int(row["ts"])
-    candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
-
-    indicators = {
-        "eth_close": float(row["close"]),
-        "middle": float(middle.iloc[decision_index]),
-        "upper": float(upper.iloc[decision_index]),
-        "lower": float(lower.iloc[decision_index]),
-        "bandwidth": float(bandwidth.iloc[decision_index]),
-        "bandwidth_threshold": float(threshold.iloc[decision_index]),
-        "eth_vol_96": float(eth_vol.iloc[decision_index]),
-    }
-
-    if state.last_candle_ts is not None and candle_ts <= state.last_candle_ts:
-        return state, {
-            "decision_candle_ts": candle_ts,
-            "decision_candle_time": candle_time,
-            "signal": "state_replay",
-            "target_side": state.active_side or "flat",
-            "indicators": indicators,
-        }
-
-    next_state = StrategyState(candle_ts, state.active_side, state.entry_price, state.entry_candle_ts, state.cooldown_until_ts)
-    signal = "hold"
-    target_side = state.active_side or "flat"
-
-    if state.active_side is not None:
-        entry_price = float(state.entry_price)
-        stop = entry_price * (1.0 - STOP_LOSS_PCT if state.active_side == "long" else 1.0 + STOP_LOSS_PCT)
-        stop_hit = (state.active_side == "long" and float(row["low"]) <= stop) or (state.active_side == "short" and float(row["high"]) >= stop)
-        middle_exit = (state.active_side == "long" and float(row["close"]) < indicators["middle"] * (1.0 - MIDDLE_EXIT_BUFFER_PCT)) or (
-            state.active_side == "short" and float(row["close"]) > indicators["middle"] * (1.0 + MIDDLE_EXIT_BUFFER_PCT)
-        )
-        if stop_hit or middle_exit:
-            signal = "exit_stop" if stop_hit else "exit_middle"
-            target_side = "flat"
-            next_state = StrategyState(
-                candle_ts,
-                None,
-                None,
-                None,
-                candle_ts + (COOLDOWN_BARS * 900_000),
-            )
-    else:
-        cooldown_ok = state.cooldown_until_ts is None or candle_ts >= state.cooldown_until_ts
-        compressed = indicators["bandwidth"] <= indicators["bandwidth_threshold"]
-        vol_ok = indicators["eth_vol_96"] <= ETH_VOL_CAP
-        if cooldown_ok and compressed and vol_ok and float(row["close"]) > indicators["upper"]:
-            signal = "entry_long"
-            target_side = "long"
-            next_state = StrategyState(candle_ts, "long", float(row["close"]), candle_ts, state.cooldown_until_ts)
-        elif cooldown_ok and compressed and vol_ok and float(row["close"]) < indicators["lower"]:
-            signal = "entry_short"
-            target_side = "short"
-            next_state = StrategyState(candle_ts, "short", float(row["close"]), candle_ts, state.cooldown_until_ts)
-
-    return next_state, {
-        "decision_candle_ts": candle_ts,
-        "decision_candle_time": candle_time,
-        "signal": signal,
-        "target_side": target_side,
-        "indicators": indicators,
-        "params": {
-            "band_length": BAND_LENGTH,
-            "bandwidth_lookback": BANDWIDTH_LOOKBACK,
-            "bandwidth_quantile": BANDWIDTH_QUANTILE,
-            "stop_loss_pct": STOP_LOSS_PCT,
-            "middle_exit_buffer_pct": MIDDLE_EXIT_BUFFER_PCT,
-            "eth_vol_cap": ETH_VOL_CAP,
-            "cooldown_bars": COOLDOWN_BARS,
-            "side_mode": "both",
-        },
-    }
-
-
 def account_current_position(client: OkxClient, margin_per_unit_usdt: float) -> tuple[TargetPosition, dict[str, object]]:
     positions = client.get_positions(SYMBOL)
     metadata = client.get_instrument_meta(SYMBOL)
@@ -243,6 +174,22 @@ def run_once(
         frame = load_live_frame(client)
     next_state, signal = signal_from_frame(frame, previous_state)
     current, account = account_current_position(client, margin_per_unit_usdt)
+    if previous_state.active_side is not None and current.known and current.side == "flat":
+        decision_candle_ts = int(signal["decision_candle_ts"])
+        next_state = StrategyState(
+            decision_candle_ts,
+            None,
+            None,
+            None,
+            decision_candle_ts + (COOLDOWN_BARS * 900_000),
+            0,
+            None,
+        )
+        signal = {
+            **signal,
+            "signal": "external_flat_sync",
+            "target_side": "flat",
+        }
     target = target_position(signal, current)
     plan = plan_position_delta(current, target)
     orders = ()
@@ -261,12 +208,13 @@ def run_once(
             max_total_margin_usdt=max_total_margin_usdt,
             client_order_id_prefix=f"bbsq-{signal['decision_candle_ts']}",
             stop_loss_pct=STOP_LOSS_PCT,
+            take_profit_pct=TAKE_PROFIT_PCT,
         )
     snapshot = {
         "created_at": now_iso(),
         "mode": "bb_squeeze_live_executor" if submit_live else "bb_squeeze_dry_run_executor",
         "orders_submitted": 0,
-        "strategy": "bb-squeeze-l48-bw960-q0.25-sl0.01-tpnone-both-none-vc0.006-ddnone-cd24-mxbuf0.0005-mxc1",
+        "strategy": strategy_name(),
         "previous_state": asdict(previous_state),
         "next_state": asdict(next_state),
         "signal": signal,
@@ -336,19 +284,23 @@ def main() -> int:
 
     frame: pd.DataFrame | None = None
     while True:
-        frame = refresh_live_frame(OkxClient(), frame)
-        state = load_state(args.state_dir / STATE_FILE)
-        _, loop_signal = signal_from_frame(frame, state)
-        if loop_signal["signal"] != "state_replay":
-            snapshot = run_once(
-                state_dir=args.state_dir,
-                margin_per_unit_usdt=margin_per_unit_usdt,
-                max_new_margin_usdt=max_new_margin_usdt,
-                max_total_margin_usdt=max_total_margin_usdt,
-                submit_live=args.submit_live,
-                frame=frame,
-            )
-            print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
+        try:
+            frame = refresh_live_frame(OkxClient(), frame)
+            state = load_state(args.state_dir / STATE_FILE)
+            _, loop_signal = signal_from_frame(frame, state)
+            if loop_signal["signal"] != "state_replay":
+                snapshot = run_once(
+                    state_dir=args.state_dir,
+                    margin_per_unit_usdt=margin_per_unit_usdt,
+                    max_new_margin_usdt=max_new_margin_usdt,
+                    max_total_margin_usdt=max_total_margin_usdt,
+                    submit_live=args.submit_live,
+                    frame=frame,
+                )
+                print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
+        except ValueError as exc:
+            append_loop_error(args.state_dir, str(exc))
+            print(json.dumps({"created_at": now_iso(), "execution_error": str(exc)}, sort_keys=True), flush=True)
         time.sleep(args.poll_seconds)
     return 0
 

+ 63 - 0
scripts/run_high_frequency_portfolio_observer.py

@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import time
+from datetime import UTC, datetime
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from scripts import build_high_frequency_portfolio_observation_intent as intent
+
+
+ROOT = Path(__file__).resolve().parents[1]
+STATE_DIR = ROOT / "var" / "high-frequency-portfolio"
+
+
+def now_iso() -> str:
+    return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
+
+
+def append_jsonl(path: Path, payload: dict[str, object]) -> None:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    with path.open("a", encoding="utf-8") as handle:
+        handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
+
+
+def run_once(state_dir: Path) -> dict[str, object]:
+    state_dir.mkdir(parents=True, exist_ok=True)
+    payload = intent.build_payload()
+    payload["created_at"] = now_iso()
+    intent.REPORT_DIR.mkdir(parents=True, exist_ok=True)
+    intent.OUTPUT_JSON.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    intent.OUTPUT_MD.write_text(intent.markdown(payload), encoding="utf-8")
+    (state_dir / "heartbeat.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    append_jsonl(state_dir / "observer-events.jsonl", payload)
+    return payload
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Run high-frequency portfolio read-only observer.")
+    parser.add_argument("--state-dir", type=Path, default=STATE_DIR)
+    parser.add_argument("--interval-seconds", type=int, default=300)
+    parser.add_argument("--once", action="store_true")
+    args = parser.parse_args()
+    while True:
+        try:
+            payload = run_once(args.state_dir)
+            print(json.dumps(payload, indent=2, sort_keys=True))
+        except Exception as exc:
+            error = {"created_at": now_iso(), "mode": "high_frequency_portfolio_readonly_observer", "submitted_orders": 0, "error": str(exc)}
+            append_jsonl(args.state_dir / "observer-events.jsonl", error)
+            print(json.dumps(error, indent=2, sort_keys=True), file=sys.stderr)
+            if args.once:
+                return 1
+        if args.once:
+            return 0
+        time.sleep(args.interval_seconds)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 165 - 0
scripts/summarize_readonly_observers.py

@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+import json
+from collections import Counter
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+OUTPUT = ROOT / "reports" / "live-recent" / "readonly-observers-snapshot.md"
+WINDOWS = {
+    "24h": timedelta(hours=24),
+    "3d": timedelta(days=3),
+    "7d": timedelta(days=7),
+}
+EVENT_FILES = {
+    "live_bb_squeeze": ROOT / "var" / "bb-squeeze-executor" / "events.jsonl",
+    "bb_squeeze_t_gated": ROOT / "var" / "bb-squeeze-t-gated-observer" / "observer-events.jsonl",
+    "calendar_fusion": ROOT / "var" / "calendar-fusion" / "observer-events.jsonl",
+    "crash_follow_short": ROOT / "var" / "crash-follow-short-observer" / "observer-events.jsonl",
+    "eth_focused_portfolio": ROOT / "var" / "eth-focused-portfolio" / "observer-events.jsonl",
+    "eth_nextgen_micro": ROOT / "var" / "eth-nextgen-micro" / "observer-events.jsonl",
+    "high_frequency_portfolio": ROOT / "var" / "high-frequency-portfolio" / "observer-events.jsonl",
+    "short_bias_readonly": ROOT / "var" / "short-bias-readonly" / "observer-events.jsonl",
+}
+
+
+def parse_time(payload: dict[str, object]) -> datetime | None:
+    value = payload.get("created_at")
+    if not isinstance(value, str):
+        return None
+    return datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+
+def read_events(path: Path) -> list[tuple[datetime, dict[str, object]]]:
+    if not path.exists():
+        return []
+    rows = []
+    for line in path.read_text(encoding="utf-8").splitlines():
+        if not line:
+            continue
+        payload = json.loads(line)
+        created_at = parse_time(payload)
+        if created_at is not None:
+            rows.append((created_at, payload))
+    return rows
+
+
+def classify(name: str, payload: dict[str, object]) -> str:
+    if "error" in payload:
+        return "error"
+    if name in {"live_bb_squeeze", "bb_squeeze_t_gated", "crash_follow_short"}:
+        signal = payload.get("signal") or {}
+        assert isinstance(signal, dict)
+        action = str(signal.get("signal") or "hold")
+        side = str(signal.get("target_side") or "flat")
+        if action in {"entry", "entry_long", "long"} or (signal.get("entry_signal") and side == "long"):
+            return "entry_long"
+        if action in {"short", "entry_short"} or (signal.get("entry_signal") and side == "short"):
+            return "entry_short"
+        if action.startswith("exit") or signal.get("exit_signal"):
+            return action if action.startswith("exit") else "exit"
+        return action
+    if name == "calendar_fusion":
+        state = payload.get("calendar_state") or {}
+        assert isinstance(state, dict)
+        if state.get("entry_signal_on_decision_candle"):
+            return "entry_long"
+        if state.get("theoretical_calendar_position_active"):
+            return "active_long"
+        return "flat"
+    if name == "eth_focused_portfolio":
+        entries = []
+        exits = []
+        for leg in payload.get("legs") or []:
+            assert isinstance(leg, dict)
+            symbol = str(leg.get("symbol") or leg.get("leg_id") or "leg")
+            if leg.get("signal") or leg.get("needs_order"):
+                entries.append(symbol)
+            if leg.get("exit_signal") or leg.get("needs_cancel"):
+                exits.append(symbol)
+        if entries:
+            return "entry_" + "+".join(sorted(set(entries)))
+        if exits:
+            return "exit_" + "+".join(sorted(set(exits)))
+        return "flat"
+    if name == "eth_nextgen_micro":
+        signal = payload.get("signal") or {}
+        assert isinstance(signal, dict)
+        intent = str(signal.get("intent") or "unknown")
+        if "long" in intent:
+            return "entry_long"
+        if "short" in intent:
+            return "entry_short"
+        return intent
+    if name == "high_frequency_portfolio":
+        target = payload.get("target") or {}
+        assert isinstance(target, dict)
+        net_unit = float(target.get("net_unit") or 0.0)
+        if net_unit > 0.0:
+            return "target_long"
+        if net_unit < 0.0:
+            return "target_short"
+        return "flat"
+    if name == "short_bias_readonly":
+        decision = payload.get("decision") or payload.get("signal") or {}
+        assert isinstance(decision, dict)
+        if decision.get("entry_signal"):
+            return "entry_short"
+        if decision.get("exit_signal"):
+            return "exit_short"
+        return "hold"
+    return "unknown"
+
+
+def compact(counter: Counter[str]) -> str:
+    return ", ".join(f"{key}:{value}" for key, value in counter.most_common(5)) or "none"
+
+
+def readout(name: str, counters: dict[str, Counter[str]]) -> str:
+    seven = counters["7d"]
+    entries = sum(value for key, value in seven.items() if key.startswith("entry"))
+    errors = seven.get("error", 0)
+    total = sum(seven.values())
+    if name == "live_bb_squeeze":
+        return "live path active; recent 24h no entry"
+    if total and errors / total > 0.5:
+        return "recent errors dominated; fixed services need fresh observation"
+    if entries:
+        return f"has complementary entries: {entries} in 7d"
+    return "healthy but no recent entries"
+
+
+def main() -> int:
+    now = datetime.now(UTC)
+    lines = [
+        "# Read-only observer snapshot",
+        "",
+        f"Generated at `{now.isoformat().replace('+00:00', 'Z')}`.",
+        "",
+        "| observer | latest_event_utc | 24h | 3d | 7d | readout |",
+        "| --- | --- | --- | --- | --- | --- |",
+    ]
+    for name, path in EVENT_FILES.items():
+        events = read_events(path)
+        latest = events[-1][0].isoformat().replace("+00:00", "Z") if events else "missing"
+        counters = {
+            label: Counter(classify(name, payload) for created_at, payload in events if created_at >= now - window)
+            for label, window in WINDOWS.items()
+        }
+        lines.append(
+            f"| {name} | {latest} | {compact(counters['24h'])} | {compact(counters['3d'])} | "
+            f"{compact(counters['7d'])} | {readout(name, counters)} |"
+        )
+    recent = ROOT / "reports" / "eth-exploration" / "current-strategy-recent-activity-report.md"
+    if recent.exists():
+        lines.extend(["", "## Latest server backtest", "", recent.read_text(encoding="utf-8")])
+    OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+    OUTPUT.write_text("\n".join(lines) + "\n", encoding="utf-8")
+    print(f"wrote {OUTPUT}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 55 - 0
tests/test_bb_squeeze_strategy.py

@@ -0,0 +1,55 @@
+from dataclasses import replace
+
+import pytest
+
+from okx_codex_trader.bb_squeeze_strategy import EMPTY_STATE, signal_from_frame, strategy_name
+from okx_codex_trader.models import Candle
+from scripts.run_bb_squeeze_executor import aligned_frame_from_candles
+from tests.test_run_bb_squeeze_executor import btc_up_candles, middle_exit_candles
+
+
+def live_frame(candles: list[Candle]):
+    return aligned_frame_from_candles(candles, btc_up_candles([candle.ts for candle in candles]))
+
+
+def test_strategy_name_includes_breakeven_parameters() -> None:
+    assert strategy_name().endswith("-be0.008-0.001")
+
+
+def test_breakeven_protection_uses_prior_confirmed_mfe() -> None:
+    candles = middle_exit_candles(1.0)
+    candles[-1] = Candle(
+        symbol="ETH-USDT-SWAP",
+        ts=candles[-1].ts,
+        open=100.4,
+        high=100.5,
+        low=100.05,
+        close=100.2,
+        volume=1_000.0,
+    )
+    state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.008)
+
+    next_state, signal = signal_from_frame(live_frame(candles), state)
+
+    assert signal["signal"] == "exit_breakeven"
+    assert signal["target_side"] == "flat"
+    assert next_state.active_side is None
+
+
+def test_favorable_move_updates_without_same_candle_breakeven_exit() -> None:
+    candles = middle_exit_candles(1.0)
+    candles[-1] = Candle(
+        symbol="ETH-USDT-SWAP",
+        ts=candles[-1].ts,
+        open=100.0,
+        high=100.9,
+        low=100.0,
+        close=100.4,
+        volume=1_000.0,
+    )
+    state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.0)
+
+    next_state, signal = signal_from_frame(live_frame(candles), state)
+
+    assert signal["signal"] == "hold"
+    assert next_state.max_favorable_move_pct == pytest.approx(0.009)

+ 38 - 0
tests/test_candles.py

@@ -0,0 +1,38 @@
+from pathlib import Path
+
+from okx_codex_trader.candles import align_candles_by_ts, load_candles_csv
+from okx_codex_trader.models import Candle
+
+
+def test_load_candles_csv(tmp_path: Path) -> None:
+    symbol_dir = tmp_path / "ETH-USDT-SWAP"
+    symbol_dir.mkdir()
+    (symbol_dir / "15m.csv").write_text(
+        "ts,open,high,low,close,volume\n"
+        "1000,10,11,9,10.5,123\n"
+        "2000,10.5,12,10,11.5,456\n",
+        encoding="utf-8",
+    )
+
+    candles = load_candles_csv(tmp_path, "ETH-USDT-SWAP", "15m")
+
+    assert candles == [
+        Candle("ETH-USDT-SWAP", 1000, 10.0, 11.0, 9.0, 10.5, 123.0),
+        Candle("ETH-USDT-SWAP", 2000, 10.5, 12.0, 10.0, 11.5, 456.0),
+    ]
+
+
+def test_align_candles_by_ts_keeps_shared_timestamps() -> None:
+    left = [
+        Candle("ETH-USDT-SWAP", 1, 1.0, 1.0, 1.0, 1.0, 1.0),
+        Candle("ETH-USDT-SWAP", 2, 2.0, 2.0, 2.0, 2.0, 2.0),
+    ]
+    right = [
+        Candle("BTC-USDT-SWAP", 2, 20.0, 20.0, 20.0, 20.0, 20.0),
+        Candle("BTC-USDT-SWAP", 3, 30.0, 30.0, 30.0, 30.0, 30.0),
+    ]
+
+    aligned_left, aligned_right = align_candles_by_ts(left, right)
+
+    assert aligned_left == [left[1]]
+    assert aligned_right == [right[0]]

+ 29 - 0
tests/test_explore_ultrashort.py

@@ -333,6 +333,35 @@ def test_build_rsi2_long_guarded_price_twap_candidate_names_and_warmup():
     assert candidate.warmup_bars == 240
 
 
+def test_price_twap_signal_exit_preserves_unused_cash():
+    module = load_explore_module()
+    candles = [
+        Candle(symbol="ETH-USDT-SWAP", ts=0, open=100.0, high=100.0, low=100.0, close=100.0, volume=1.0),
+        Candle(symbol="ETH-USDT-SWAP", ts=60_000, open=100.0, high=100.0, low=100.0, close=100.0, volume=1.0),
+        Candle(symbol="ETH-USDT-SWAP", ts=120_000, open=101.0, high=101.0, low=101.0, close=101.0, volume=1.0),
+        Candle(symbol="ETH-USDT-SWAP", ts=180_000, open=101.0, high=101.0, low=99.9, close=101.0, volume=1.0),
+        Candle(symbol="ETH-USDT-SWAP", ts=240_000, open=102.0, high=102.0, low=102.0, close=102.0, volume=1.0),
+    ]
+
+    result = module.run_rsi2_long_guarded_price_twap_segment(
+        candles=candles,
+        leverage=1,
+        warmup_bars=1,
+        trend_sma=2,
+        rsi_threshold=100.0,
+        exit_rsi=0.0,
+        stop_loss_pct=0.5,
+        max_hold_bars=10,
+        entry_offsets=(0.01, 0.03, 0.05),
+        entry_valid_bars=1,
+        fill_buffer=0.0,
+    )
+
+    assert result.trade_count == 1
+    assert result.trades[0]["cost_weight"] == pytest.approx(1 / 3)
+    assert result.total_return == pytest.approx((102.0 / (101.0 * 0.99) - 1.0) / 3)
+
+
 def test_build_trend_rsi_bb_long_candidate_names_and_warmup():
     module = load_explore_module()
 

+ 4 - 2
tests/test_live_execution.py

@@ -134,6 +134,7 @@ def test_render_market_order_bodies_builds_open_order_body():
         max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-1000",
         stop_loss_pct=0.01,
+        take_profit_pct=0.03,
     )
 
     assert len(orders) == 1
@@ -146,7 +147,7 @@ def test_render_market_order_bodies_builds_open_order_body():
         "ordType": "market",
         "sz": "5",
         "clOrdId": "eth10001open",
-        "attachAlgoOrds": [{"slTriggerPx": "2970", "slOrdPx": "-1"}],
+        "attachAlgoOrds": [{"slTriggerPx": "2970", "slOrdPx": "-1", "tpTriggerPx": "3090", "tpOrdPx": "-1"}],
     }
 
 
@@ -167,6 +168,7 @@ def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
         max_total_margin_usdt=1000.0,
         client_order_id_prefix="eth-2000",
         stop_loss_pct=0.01,
+        take_profit_pct=0.03,
     )
 
     assert [order.body for order in orders] == [
@@ -188,7 +190,7 @@ def test_render_market_order_bodies_builds_reduce_only_close_before_reverse():
             "ordType": "market",
             "sz": "5",
             "clOrdId": "eth20002reverse",
-            "attachAlgoOrds": [{"slTriggerPx": "3030", "slOrdPx": "-1"}],
+            "attachAlgoOrds": [{"slTriggerPx": "3030", "slOrdPx": "-1", "tpTriggerPx": "2910", "tpOrdPx": "-1"}],
         },
     ]
 

+ 23 - 1
tests/test_okx_client.py

@@ -781,8 +781,30 @@ def test_market_order_body_attaches_stop_loss_for_open_order():
     }
 
 
+def test_market_order_body_attaches_stop_loss_and_take_profit_for_open_order():
+    assert OkxClient.build_market_order_body(
+        symbol="ETH-USDT-SWAP",
+        side="buy",
+        pos_side="long",
+        size=1.38,
+        client_order_id="eth-open-1",
+        reduce_only=False,
+        stop_loss_trigger_price=2140.25,
+        take_profit_trigger_price=2225.5,
+    ) == {
+        "instId": "ETH-USDT-SWAP",
+        "tdMode": "isolated",
+        "side": "buy",
+        "posSide": "long",
+        "ordType": "market",
+        "sz": "1.38",
+        "clOrdId": "eth-open-1",
+        "attachAlgoOrds": [{"slTriggerPx": "2140.25", "slOrdPx": "-1", "tpTriggerPx": "2225.5", "tpOrdPx": "-1"}],
+    }
+
+
 def test_market_order_body_rejects_stop_loss_on_reduce_only_order():
-    with pytest.raises(ValueError, match="stop loss is invalid"):
+    with pytest.raises(ValueError, match="attached TP/SL is invalid"):
         OkxClient.build_market_order_body(
             symbol="ETH-USDT-SWAP",
             side="sell",

+ 51 - 0
tests/test_research_metrics.py

@@ -0,0 +1,51 @@
+import pandas as pd
+
+from okx_codex_trader.research_metrics import equity_metrics, horizon_rows, trade_stats, worst_month
+
+
+def test_equity_metrics_calculates_return_drawdown_and_calmar() -> None:
+    frame = pd.DataFrame(
+        [
+            {"ts": pd.Timestamp("2025-01-01", tz="UTC"), "equity": 100.0},
+            {"ts": pd.Timestamp("2025-07-01", tz="UTC"), "equity": 80.0},
+            {"ts": pd.Timestamp("2026-01-01", tz="UTC"), "equity": 120.0},
+        ]
+    )
+
+    metrics = equity_metrics(frame, int(frame["ts"].iloc[0].timestamp() * 1000), int(frame["ts"].iloc[-1].timestamp() * 1000))
+
+    assert round(metrics["net_total_return"], 6) == 0.2
+    assert round(metrics["net_max_drawdown"], 6) == 0.2
+    assert metrics["net_annualized_return"] > 0.19
+    assert metrics["net_calmar"] > 0.9
+
+
+def test_horizon_rows_uses_cutoff_equity_when_history_exists() -> None:
+    frame = pd.DataFrame(
+        [
+            {"ts": pd.Timestamp("2025-01-01", tz="UTC"), "equity": 100.0},
+            {"ts": pd.Timestamp("2026-01-01", tz="UTC"), "equity": 150.0},
+            {"ts": pd.Timestamp("2026-02-01", tz="UTC"), "equity": 120.0},
+        ]
+    )
+    last_ts = int(pd.Timestamp("2026-02-01", tz="UTC").timestamp() * 1000)
+
+    rows = horizon_rows(frame, last_ts, horizons=(("30d", pd.DateOffset(days=30)),))
+
+    assert rows[0]["horizon"] == "30d"
+    assert rows[0]["horizon_start"] == "2026-01-02 00:00"
+    assert rows[0]["net_total_return"] < 0.0
+
+
+def test_trade_stats_and_worst_month() -> None:
+    stats = trade_stats([{"return_pct": 2.0}, {"return_pct": -1.0}, {"return_pct": 4.0}])
+    frame = pd.DataFrame(
+        [
+            {"ts": pd.Timestamp("2026-01-01", tz="UTC"), "equity": 100.0},
+            {"ts": pd.Timestamp("2026-01-31", tz="UTC"), "equity": 120.0},
+            {"ts": pd.Timestamp("2026-02-28", tz="UTC"), "equity": 90.0},
+        ]
+    )
+
+    assert stats == {"avg_return_pct": 5.0 / 3.0, "payoff_ratio": 3.0, "profit_factor": 6.0}
+    assert worst_month(frame) == ("2026-02", -0.25)

+ 178 - 8
tests/test_run_bb_squeeze_executor.py

@@ -1,7 +1,10 @@
 from dataclasses import replace
 
+import pytest
+
+from okx_codex_trader.live_execution import TargetPosition
 from okx_codex_trader.models import Candle
-from scripts.run_bb_squeeze_executor import EMPTY_STATE, frame_from_candles, refresh_live_frame, signal_from_frame
+from scripts.run_bb_squeeze_executor import EMPTY_STATE, aligned_frame_from_candles, refresh_live_frame, run_once, save_state, signal_from_frame, strategy_name
 
 
 def candles_with_latest_breakout() -> list[Candle]:
@@ -24,9 +27,50 @@ def candles_with_latest_breakout() -> list[Candle]:
     return candles
 
 
+def btc_up_candles(timestamps: list[int]) -> list[Candle]:
+    return [
+        Candle(
+            symbol="BTC-USDT-SWAP",
+            ts=ts,
+            open=100.0 + index * 0.01,
+            high=100.0 + index * 0.01,
+            low=100.0 + index * 0.01,
+            close=100.0 + index * 0.01,
+            volume=1_000.0,
+        )
+        for index, ts in enumerate(timestamps)
+    ]
+
+
+def btc_down_candles(timestamps: list[int]) -> list[Candle]:
+    return [
+        Candle(
+            symbol="BTC-USDT-SWAP",
+            ts=ts,
+            open=200.0 - index * 0.01,
+            high=200.0 - index * 0.01,
+            low=200.0 - index * 0.01,
+            close=200.0 - index * 0.01,
+            volume=1_000.0,
+        )
+        for index, ts in enumerate(timestamps)
+    ]
+
+
+def live_frame(candles: list[Candle]):
+    return aligned_frame_from_candles(candles, btc_up_candles([candle.ts for candle in candles]))
+
+
+def test_strategy_name_matches_live_parameters() -> None:
+    assert strategy_name() == (
+        "bb-rr-time-l96-bw960-q0.25-sl0.01-rr3-hybrid_signal_rr-both-btc-up-"
+        "vc0.006-ddnone-cd24-mxbuf0.001-mxc1-entryweekday-openexitskip-be0.008-0.001"
+    )
+
+
 def test_signal_uses_latest_confirmed_candle() -> None:
     candles = candles_with_latest_breakout()
-    frame = frame_from_candles(candles)
+    frame = live_frame(candles)
 
     next_state, signal = signal_from_frame(frame, EMPTY_STATE)
 
@@ -36,7 +80,7 @@ def test_signal_uses_latest_confirmed_candle() -> None:
 
 def test_seen_latest_candle_replays_without_order_signal() -> None:
     candles = candles_with_latest_breakout()
-    frame = frame_from_candles(candles)
+    frame = live_frame(candles)
     state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
 
     _, signal = signal_from_frame(frame, state)
@@ -47,7 +91,7 @@ def test_seen_latest_candle_replays_without_order_signal() -> None:
 
 def test_loop_predicate_skips_seen_decision_candle() -> None:
     candles = candles_with_latest_breakout()
-    frame = frame_from_candles(candles)
+    frame = live_frame(candles)
     state = replace(EMPTY_STATE, last_candle_ts=candles[-1].ts)
 
     _, signal = signal_from_frame(frame, state)
@@ -84,7 +128,7 @@ def test_refresh_live_frame_fetches_recent_candles_after_initial_load() -> None:
     initial = refresh_live_frame(client, None)
     refreshed = refresh_live_frame(client, initial)
 
-    assert client.limits == [1_200, 20]
+    assert client.limits == [1_200, 1_200, 20, 20]
     assert int(refreshed.iloc[-1]["ts"]) == int(initial.iloc[-1]["ts"]) + 900_000
 
 
@@ -108,9 +152,16 @@ def middle_exit_candles(close_multiplier: float) -> list[Candle]:
     return candles
 
 
+def shift_candles(candles: list[Candle], offset_ms: int) -> list[Candle]:
+    return [
+        Candle(candle.symbol, candle.ts + offset_ms, candle.open, candle.high, candle.low, candle.close, candle.volume)
+        for candle in candles
+    ]
+
+
 def test_short_middle_exit_requires_buffer_break() -> None:
     candles = middle_exit_candles(1.0004)
-    frame = frame_from_candles(candles)
+    frame = live_frame(candles)
     state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
 
     _, signal = signal_from_frame(frame, state)
@@ -120,11 +171,130 @@ def test_short_middle_exit_requires_buffer_break() -> None:
 
 
 def test_short_middle_exit_triggers_after_buffer_break() -> None:
-    candles = middle_exit_candles(1.001)
-    frame = frame_from_candles(candles)
+    candles = shift_candles(middle_exit_candles(1.002), 12 * 3_600_000)
+    frame = live_frame(candles)
     state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
 
     _, signal = signal_from_frame(frame, state)
 
     assert signal["signal"] == "exit_middle"
     assert signal["target_side"] == "flat"
+
+
+def test_us_open_middle_exit_is_skipped() -> None:
+    candles = middle_exit_candles(1.001)
+    frame = live_frame(candles)
+    state = replace(EMPTY_STATE, active_side="short", entry_price=101.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
+
+    _, signal = signal_from_frame(frame, state)
+
+    assert signal["signal"] == "hold"
+    assert signal["target_side"] == "short"
+
+
+def test_long_take_profit_triggers_exit() -> None:
+    candles = middle_exit_candles(1.0)
+    candles[-1] = Candle(
+        symbol="ETH-USDT-SWAP",
+        ts=candles[-1].ts,
+        open=100.0,
+        high=103.1,
+        low=100.0,
+        close=102.0,
+        volume=1_000.0,
+    )
+    frame = live_frame(candles)
+    state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts)
+
+    _, signal = signal_from_frame(frame, state)
+
+    assert signal["signal"] == "exit_take_profit"
+    assert signal["target_side"] == "flat"
+
+
+def test_long_breakeven_protection_updates_after_favorable_move() -> None:
+    candles = middle_exit_candles(1.0)
+    candles[-1] = Candle(
+        symbol="ETH-USDT-SWAP",
+        ts=candles[-1].ts,
+        open=100.0,
+        high=100.9,
+        low=100.0,
+        close=100.4,
+        volume=1_000.0,
+    )
+    frame = live_frame(candles)
+    state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.0)
+
+    next_state, signal = signal_from_frame(frame, state)
+
+    assert signal["signal"] == "hold"
+    assert next_state.max_favorable_move_pct == pytest.approx(0.009)
+
+
+def test_long_breakeven_protection_triggers_exit_after_prior_favorable_move() -> None:
+    candles = middle_exit_candles(1.0)
+    candles[-1] = Candle(
+        symbol="ETH-USDT-SWAP",
+        ts=candles[-1].ts,
+        open=100.4,
+        high=100.5,
+        low=100.05,
+        close=100.2,
+        volume=1_000.0,
+    )
+    frame = live_frame(candles)
+    state = replace(EMPTY_STATE, active_side="long", entry_price=100.0, entry_candle_ts=candles[-2].ts, last_candle_ts=candles[-2].ts, max_favorable_move_pct=0.008)
+
+    next_state, signal = signal_from_frame(frame, state)
+
+    assert signal["signal"] == "exit_breakeven"
+    assert signal["target_side"] == "flat"
+    assert next_state.active_side is None
+
+
+def test_btc_down_filter_blocks_new_breakout_entry() -> None:
+    candles = candles_with_latest_breakout()
+    frame = aligned_frame_from_candles(candles, btc_down_candles([candle.ts for candle in candles]))
+
+    _, signal = signal_from_frame(frame, EMPTY_STATE)
+
+    assert signal["signal"] == "hold"
+    assert signal["target_side"] == "flat"
+
+
+def test_run_once_syncs_external_flat_without_reopening(monkeypatch, tmp_path) -> None:
+    frame = live_frame(middle_exit_candles(0.999))
+    state_dir = tmp_path / "state"
+    previous = replace(
+        EMPTY_STATE,
+        active_side="short",
+        entry_price=101.0,
+        entry_candle_ts=int(frame.iloc[-2]["ts"]),
+        last_candle_ts=int(frame.iloc[-2]["ts"]),
+        middle_exit_streak=0,
+    )
+    save_state(state_dir / "runtime-state.json", previous)
+
+    monkeypatch.setattr("scripts.run_bb_squeeze_executor.load_config", lambda: object())
+    monkeypatch.setattr(
+        "scripts.run_bb_squeeze_executor.account_current_position",
+        lambda client, margin: (
+            TargetPosition(side="flat", unit=0.0, known=True, reason="no open OKX position", contracts=0.0),
+            {"positions": [], "instrument_meta": {"ct_val": 0.1, "lot_sz": 0.01, "min_sz": 0.01}, "mark_price": 100.0},
+        ),
+    )
+
+    snapshot = run_once(
+        state_dir=state_dir,
+        margin_per_unit_usdt=100.0,
+        max_new_margin_usdt=100.0,
+        max_total_margin_usdt=200.0,
+        submit_live=False,
+        frame=frame,
+    )
+
+    assert snapshot["signal"]["signal"] == "external_flat_sync"
+    assert snapshot["target_position"]["side"] == "flat"
+    assert snapshot["rendered_orders"] == []
+    assert snapshot["next_state"]["active_side"] is None

+ 27 - 0
tests/test_search_eth_time_cycle_filters.py

@@ -0,0 +1,27 @@
+from datetime import datetime, timezone
+
+from okx_codex_trader.time_rules import entry_allowed, is_us_open_window, time_bucket
+
+
+def ms(dt: datetime) -> int:
+    return int(dt.timestamp() * 1000)
+
+
+def test_weekday_filter_uses_new_york_day_not_utc_day() -> None:
+    ny_friday_evening_utc_saturday = ms(datetime(2026, 5, 22, 23, 30, tzinfo=timezone.utc))
+    ny_sunday_evening_utc_monday = ms(datetime(2026, 5, 25, 0, 0, tzinfo=timezone.utc))
+
+    assert entry_allowed(ny_friday_evening_utc_saturday, "weekday") is True
+    assert time_bucket(ny_friday_evening_utc_saturday) == "us_afterhours"
+    assert entry_allowed(ny_sunday_evening_utc_monday, "weekday") is False
+    assert time_bucket(ny_sunday_evening_utc_monday) == "weekend"
+
+
+def test_us_open_window_respects_dst() -> None:
+    summer_open = ms(datetime(2026, 5, 20, 13, 30, tzinfo=timezone.utc))
+    winter_open = ms(datetime(2026, 1, 20, 14, 30, tzinfo=timezone.utc))
+    summer_preopen = ms(datetime(2026, 5, 20, 13, 15, tzinfo=timezone.utc))
+
+    assert is_us_open_window(summer_open) is True
+    assert is_us_open_window(winter_open) is True
+    assert is_us_open_window(summer_preopen) is False

+ 10 - 0
tests/test_summarize_readonly_observers.py

@@ -0,0 +1,10 @@
+from scripts import summarize_readonly_observers as module
+
+
+def test_classify_high_frequency_portfolio_flat() -> None:
+    assert module.classify("high_frequency_portfolio", {"target": {"net_unit": 0.0}}) == "flat"
+
+
+def test_classify_high_frequency_portfolio_direction() -> None:
+    assert module.classify("high_frequency_portfolio", {"target": {"net_unit": 0.25}}) == "target_long"
+    assert module.classify("high_frequency_portfolio", {"target": {"net_unit": -0.25}}) == "target_short"