chore: track logo, hyperopt design docs, gitignore playwright-mcp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-05 17:06:53 +01:00
parent 53317d9af6
commit 4c03beb514
4 changed files with 661 additions and 0 deletions

1
.gitignore vendored
View File

@@ -184,3 +184,4 @@ test_data/databento/samples/
services/*/load_tests/results/
services/*/load_tests/*.json
!services/*/load_tests/package.json
.playwright-mcp/

View File

@@ -0,0 +1,70 @@
# Hyperopt Degenerate Trial Fix
## Problem
7/9 DQN hyperopt trials produce degenerate policies (all-buy or all-hold) that generate 0-1 trades in backtest evaluation. All degenerates receive identical objective=1.25, creating a flat plateau that gives PSO zero gradient signal.
Root cause chain:
1. 45-action factored space collapses to 3 legacy actions (Buy/Sell/Hold) at eval time
2. EvaluationEngine treats consecutive same-direction actions as no-ops
3. Objective function can't distinguish between failure modes (all-buy vs all-hold vs insufficient trades)
## Solution: Three-Part Fix
### Part 1: Exposure-Aware Evaluation Engine
Add `process_bar_factored(&FactoredAction)` to EvaluationEngine that tracks continuous position exposure (-1.0 to +1.0):
- Long100→Long50 generates a partial close trade (delta=-0.5)
- Long50→Short100 generates a reversal trade (delta=-1.5)
- Long100→Long100 = no trade (delta=0)
- Transaction cost uses FactoredAction's order type (Market=0.15%, Limit=0.05%, IoC=0.10%)
- Position sizing: `effective_size = target_exposure * kelly_fraction` (multiplicative)
File: `crates/ml/src/evaluation/engine.rs` (~50 lines)
### Part 2: Graduated Trade Insufficiency Penalty
Replace flat 1.25 plateau with smooth, differentiated penalties in `extract_objective()`:
```
min_expected_trades = total_bars / 500 (~448 for 224K bars)
penalty = match total_trades {
0 => 10.0, // Model does nothing
1..10 => 5.0 + 5.0 * (1.0 - trades/10.0), // Near-degenerate
10..min => 2.0 * (1.0 - trades/min_expected), // Insufficient activity
_ => 0.0, // Rely on Sharpe/Sortino
}
```
Additive on objective (higher=worse for PSO minimization). Each failure mode gets a unique value → PSO gets gradient signal.
File: `crates/ml/src/hyperopt/adapters/dqn.rs` (~25 lines in `extract_objective()`)
### Part 3: Wire Factored Eval into Hyperopt Backtest
Replace legacy action collapse in backtest loop:
```rust
// Before: factored → legacy → Action::Buy/Sell/Hold → engine.process_bar()
// After: factored → engine.process_bar_factored()
```
File: `crates/ml/src/hyperopt/adapters/dqn.rs` (~3 lines in backtest loop)
## Expected Impact
- Degenerate trials now produce different objectives (10.0 vs 5.0 vs 2.0 vs 1.25) → PSO can navigate
- Factored eval generates more trades from partial position changes → richer performance metrics
- Models that learn Long100/Long50 switching get credit instead of being penalized
- Combined: PSO should find viable configs in 5-8 initial trials instead of relying on luck
## Files Changed
| File | Change | ~Lines |
|------|--------|--------|
| evaluation/engine.rs | process_bar_factored() | 50 |
| hyperopt/adapters/dqn.rs | extract_objective() penalty | 25 |
| hyperopt/adapters/dqn.rs | Backtest loop wiring | 3 |
| Tests | Unit tests | 80 |
| **Total** | | **~160** |

View File

@@ -0,0 +1,590 @@
# Hyperopt Degenerate Trial Fix — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Fix the flat objective plateau (1.25) that makes 7/9 hyperopt trials indistinguishable, giving PSO zero gradient signal in 26D space.
**Architecture:** Three surgical changes: (1) Add exposure-aware eval to EvaluationEngine so factored actions generate trades from partial position changes, (2) Add graduated trade-count penalty to `extract_objective()` so different failure modes produce unique objective values, (3) Wire the factored eval into the hyperopt backtest loop (replace legacy 3-action collapse).
**Tech Stack:** Rust, candle ML framework, EvaluationEngine, DQN hyperopt adapter, PSO optimizer
---
### Task 1: Add `process_bar_factored()` to EvaluationEngine
**Files:**
- Modify: `crates/ml/src/evaluation/engine.rs` (add new fields + method after line 184)
**Step 1: Add new fields to EvaluationEngine struct**
Add `current_exposure` field to track continuous position exposure. The existing `current_position` + `trades` + `kelly_fraction` remain unchanged.
In `crates/ml/src/evaluation/engine.rs`, add after line 60 (`pub kelly_fraction: f64,`):
```rust
/// Current position exposure for factored evaluation (-1.0 to +1.0)
/// None = not using factored eval, Some(f64) = active factored tracking
pub current_exposure: f64,
/// Entry price for current exposure-based position
pub exposure_entry_price: f32,
/// Bar index where current exposure was first entered
pub exposure_entry_bar: usize,
```
**Step 2: Initialize new fields in constructors**
In `new_with_kelly()` (line 69), add after `kelly_fraction,`:
```rust
current_exposure: 0.0,
exposure_entry_price: 0.0,
exposure_entry_bar: 0,
```
**Step 3: Add `process_bar_factored()` method**
Add after `close_position()` (after line 184):
```rust
/// Process a bar using the full 45-action factored space.
///
/// Tracks continuous exposure (-1.0 to +1.0) and generates trades on
/// any exposure change (including partial: Long100→Long50 = sell 0.5).
/// Transaction costs use the FactoredAction's order type.
pub fn process_bar_factored(
&mut self,
bar_idx: usize,
bar: &OHLCVBarF32,
action: &crate::common::action::FactoredAction,
) {
let target = action.target_exposure(); // -1.0, -0.5, 0.0, +0.5, +1.0
let delta = target - self.current_exposure;
// Update legacy action counts for compatibility with metrics
if target > 0.0 {
self.action_counts[0] += 1; // buy
} else if target < 0.0 {
self.action_counts[2] += 1; // sell
} else {
self.action_counts[1] += 1; // hold
}
const EPSILON: f64 = 1e-6;
if delta.abs() < EPSILON {
return; // No position change
}
// Record trade for the exposure change
let effective_delta = delta.abs() * self.kelly_fraction;
let fee_rate = action.transaction_cost() as f64; // 0.0015 Market, 0.0005 Limit, 0.001 IoC
// PnL from the portion being closed (if reducing or reversing)
let closing_size = if delta.signum() != self.current_exposure.signum() && self.current_exposure.abs() > EPSILON {
// Closing part (or all) of existing position
self.current_exposure.abs().min(delta.abs())
} else if delta.abs() < self.current_exposure.abs() && delta.signum() == -self.current_exposure.signum() {
delta.abs()
} else {
0.0
};
if closing_size > EPSILON {
let price_diff = bar.close - self.exposure_entry_price;
let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 };
let gross_pnl = price_diff * direction_sign * (closing_size * self.kelly_fraction) as f32;
let tx_cost = (self.exposure_entry_price.abs() + bar.close.abs()) as f64
* 0.5 * closing_size * self.kelly_fraction * fee_rate;
self.trades.push(Trade {
entry_bar_idx: self.exposure_entry_bar,
exit_bar_idx: bar_idx,
entry_price: self.exposure_entry_price,
exit_price: bar.close,
direction: if self.current_exposure > 0.0 {
"long".to_owned()
} else {
"short".to_owned()
},
pnl: gross_pnl - tx_cost as f32,
});
}
// Update exposure state
if (target.abs()) > EPSILON {
// Opening or adjusting — reset entry if crossing zero or first entry
if self.current_exposure.abs() < EPSILON || target.signum() != self.current_exposure.signum() {
self.exposure_entry_price = bar.close;
self.exposure_entry_bar = bar_idx;
}
}
self.current_exposure = target;
}
/// Close any remaining factored exposure at end of backtest
pub fn close_factored_position(&mut self, bar_idx: usize, bar: &OHLCVBarF32) {
const EPSILON: f64 = 1e-6;
if self.current_exposure.abs() < EPSILON {
return;
}
let price_diff = bar.close - self.exposure_entry_price;
let direction_sign = if self.current_exposure > 0.0 { 1.0_f32 } else { -1.0_f32 };
let size = self.current_exposure.abs() * self.kelly_fraction;
let gross_pnl = price_diff * direction_sign * size as f32;
let tx_cost = bar.close.abs() as f64 * size * 0.0015; // Market order for forced close
self.trades.push(Trade {
entry_bar_idx: self.exposure_entry_bar,
exit_bar_idx: bar_idx,
entry_price: self.exposure_entry_price,
exit_price: bar.close,
direction: if self.current_exposure > 0.0 {
"long".to_owned()
} else {
"short".to_owned()
},
pnl: gross_pnl - tx_cost as f32,
});
self.current_exposure = 0.0;
}
```
**Step 4: Run `cargo check -p ml`**
Run: `SQLX_OFFLINE=true cargo check -p ml`
Expected: compiles with 0 errors
**Step 5: Commit**
```bash
git add crates/ml/src/evaluation/engine.rs
git commit -m "feat(ml): add process_bar_factored() for exposure-aware backtesting
Tracks continuous position exposure (-1.0 to +1.0) instead of binary
Buy/Sell/Hold. Partial position changes (Long100→Long50) now generate
trades, giving hyperopt richer evaluation signal."
```
---
### Task 2: Add unit tests for `process_bar_factored()`
**Files:**
- Modify: `crates/ml/src/evaluation/engine.rs` (add `#[cfg(test)] mod tests` at bottom)
**Step 1: Write tests**
Add at the end of `crates/ml/src/evaluation/engine.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
use crate::common::action::{ExposureLevel, FactoredAction, OrderType, Urgency};
fn bar(close: f32) -> OHLCVBarF32 {
OHLCVBarF32 { timestamp: 0, open: close, high: close, low: close, close, volume: 0.0 }
}
fn market_action(exposure: ExposureLevel) -> FactoredAction {
FactoredAction::new(exposure, OrderType::Market, Urgency::Normal)
}
#[test]
fn factored_same_exposure_no_trade() {
let mut engine = EvaluationEngine::new(10000.0);
let b = bar(100.0);
engine.process_bar_factored(0, &b, &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &b, &market_action(ExposureLevel::Long100));
assert_eq!(engine.trades.len(), 0, "Same exposure should generate no trades");
}
#[test]
fn factored_partial_close_generates_trade() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &bar(110.0), &market_action(ExposureLevel::Long50));
assert_eq!(engine.trades.len(), 1, "Long100→Long50 should generate 1 trade");
assert!(engine.trades[0].pnl > 0.0, "Price went up on long = profit");
assert_eq!(engine.current_exposure, 0.5);
}
#[test]
fn factored_reversal_generates_trade() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &bar(105.0), &market_action(ExposureLevel::Short100));
assert!(engine.trades.len() >= 1, "Reversal should generate at least 1 trade");
assert_eq!(engine.current_exposure, -1.0);
}
#[test]
fn factored_flat_from_long_closes() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Long100));
engine.process_bar_factored(1, &bar(95.0), &market_action(ExposureLevel::Flat));
assert_eq!(engine.trades.len(), 1);
assert!(engine.trades[0].pnl < 0.0, "Price went down on long = loss");
assert_eq!(engine.current_exposure, 0.0);
}
#[test]
fn factored_close_at_end() {
let mut engine = EvaluationEngine::new(10000.0);
engine.process_bar_factored(0, &bar(100.0), &market_action(ExposureLevel::Short50));
engine.close_factored_position(1, &bar(90.0));
assert_eq!(engine.trades.len(), 1);
assert!(engine.trades[0].pnl > 0.0, "Price down on short = profit");
assert_eq!(engine.current_exposure, 0.0);
}
#[test]
fn factored_all_buy_still_one_trade_at_close() {
let mut engine = EvaluationEngine::new(10000.0);
for i in 0..100 {
engine.process_bar_factored(i, &bar(100.0 + i as f32), &market_action(ExposureLevel::Long100));
}
// All same exposure → 0 trades during loop
assert_eq!(engine.trades.len(), 0);
// But close at end generates 1
engine.close_factored_position(100, &bar(200.0));
assert_eq!(engine.trades.len(), 1);
}
#[test]
fn factored_alternating_generates_many_trades() {
let mut engine = EvaluationEngine::new(10000.0);
for i in 0..10 {
let action = if i % 2 == 0 {
market_action(ExposureLevel::Long100)
} else {
market_action(ExposureLevel::Short100)
};
engine.process_bar_factored(i, &bar(100.0), &action);
}
// Each reversal closes + opens = 1 trade per transition after first
assert!(engine.trades.len() >= 9, "Alternating should generate many trades: got {}", engine.trades.len());
}
}
```
**Step 2: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib evaluation::engine::tests -- --nocapture`
Expected: all 7 tests pass
**Step 3: Commit**
```bash
git add crates/ml/src/evaluation/engine.rs
git commit -m "test(ml): add unit tests for process_bar_factored()"
```
---
### Task 3: Add graduated trade insufficiency penalty to `extract_objective()`
**Files:**
- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs:3105-3258` (`extract_objective()` method)
**Step 1: Add `calculate_trade_insufficiency_penalty()` function**
Add before `extract_objective()` (before line 3105):
```rust
/// Calculate graduated penalty for insufficient trade count.
///
/// Produces different objective values for different failure modes so PSO
/// gets gradient signal instead of a flat plateau at 1.25.
///
/// Returns penalty in [0.0, 10.0] (additive on objective, higher = worse).
fn calculate_trade_insufficiency_penalty(total_trades: usize, total_bars: usize) -> f64 {
// Expected minimum trades: ~1 trade per 500 bars (8-hour avg hold at 1min)
let min_expected = (total_bars / 500).max(10);
if total_trades == 0 {
10.0 // Model does nothing at all
} else if total_trades < 10 {
5.0 + 5.0 * (1.0 - total_trades as f64 / 10.0) // 5.0-10.0 range
} else if total_trades < min_expected {
2.0 * (1.0 - total_trades as f64 / min_expected as f64) // 0.0-2.0 smooth
} else {
0.0 // Sufficient trades → rely on Sharpe/Sortino
}
}
```
**Step 2: Wire penalty into `extract_objective()`**
In `extract_objective()`, replace lines 3168-3230 (the `if let Some(backtest)` branch) with:
```rust
let objective_total = if let Some(backtest) = &metrics.backtest_metrics {
// TRADE INSUFFICIENCY PENALTY: gives PSO gradient across degenerate plateau
let trade_penalty = calculate_trade_insufficiency_penalty(
backtest.total_trades,
backtest.total_trades.max(1) * 500, // Approximate total_bars from trade count context
);
// Short-circuit: if too few trades, skip composite score (it's all zeros anyway)
if backtest.total_trades < 10 {
info!(
"DEGENERATE TRIAL: {} trades → trade_penalty={:.2} (objective={:.2})",
backtest.total_trades, trade_penalty, trade_penalty
);
trade_penalty
} else {
// Component 1: Multi-objective composite score (60% weight)
let composite_score =
0.4 * backtest.sortino_ratio +
0.3 * backtest.calmar_ratio +
0.2 * backtest.sharpe_ratio +
0.1 * backtest.omega_ratio;
// Tail risk penalty
let cvar_penalty = if backtest.cvar_95 < -0.05 { 10.0 } else { 0.0 };
// Component 2: HFT activity score (25% weight)
let hft_activity = calculate_hft_activity_score_wave10(buy_pct, sell_pct, hold_pct);
// Combine: base objective + trade insufficiency penalty
let base_objective =
-0.60 * composite_score + cvar_penalty + -0.25 * hft_activity + 0.15 * stability_penalty_raw;
let objective = base_objective + trade_penalty;
info!(
"OBJECTIVE: {:.4} = base {:.4} + trade_penalty {:.4} | trades={} composite={:.4}",
objective, base_objective, trade_penalty, backtest.total_trades, composite_score
);
objective
}
```
**Important:** We need `total_bars` in `extract_objective()`. Since it's not in `DQNMetrics`, we approximate from trade context. Alternatively, we can use a constant (224K bars is the standard dataset). Let me check if we can just use a constant:
The training data is always the full validation set (~224K bars for ES.FUT). Rather than passing `total_bars` through metrics, use the simpler approach: hardcode `min_expected = 100` (conservative — even 100 trades on 224K bars means the model is barely active, but at least it's distinguishable from 0).
Revised `calculate_trade_insufficiency_penalty()`:
```rust
fn calculate_trade_insufficiency_penalty(total_trades: usize) -> f64 {
const MIN_VIABLE_TRADES: usize = 100;
if total_trades == 0 {
10.0
} else if total_trades < 10 {
5.0 + 5.0 * (1.0 - total_trades as f64 / 10.0)
} else if total_trades < MIN_VIABLE_TRADES {
2.0 * (1.0 - total_trades as f64 / MIN_VIABLE_TRADES as f64)
} else {
0.0
}
}
```
**Step 3: Run check**
Run: `SQLX_OFFLINE=true cargo check -p ml`
Expected: compiles with 0 errors
**Step 4: Commit**
```bash
git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "feat(ml): add graduated trade insufficiency penalty to hyperopt objective
Different failure modes now produce unique objective values:
- 0 trades → 10.0 (model does nothing)
- 1 trade → 9.5 (single-direction collapse)
- 50 trades → 1.0 (insufficient activity)
- 100+ trades → 0.0 (rely on Sharpe/Sortino)
This breaks the flat 1.25 plateau that gave PSO zero gradient signal."
```
---
### Task 4: Add unit tests for trade insufficiency penalty
**Files:**
- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs` (add tests to existing `mod tests`)
**Step 1: Write tests**
Add to the existing `mod tests` block (after the last `#[test]` fn):
```rust
#[test]
fn test_trade_insufficiency_penalty_zero_trades() {
let penalty = calculate_trade_insufficiency_penalty(0);
assert!((penalty - 10.0).abs() < 1e-6, "0 trades = max penalty: {}", penalty);
}
#[test]
fn test_trade_insufficiency_penalty_one_trade() {
let penalty = calculate_trade_insufficiency_penalty(1);
assert!(penalty > 9.0 && penalty < 10.0, "1 trade near max: {}", penalty);
}
#[test]
fn test_trade_insufficiency_penalty_graduated() {
let p0 = calculate_trade_insufficiency_penalty(0);
let p1 = calculate_trade_insufficiency_penalty(1);
let p5 = calculate_trade_insufficiency_penalty(5);
let p50 = calculate_trade_insufficiency_penalty(50);
let p100 = calculate_trade_insufficiency_penalty(100);
let p500 = calculate_trade_insufficiency_penalty(500);
// Strictly decreasing (key property for PSO gradient)
assert!(p0 > p1, "0 > 1: {} > {}", p0, p1);
assert!(p1 > p5, "1 > 5: {} > {}", p1, p5);
assert!(p5 > p50, "5 > 50: {} > {}", p5, p50);
assert!(p50 > p100, "50 > 100: {} > {}", p50, p100);
assert!((p100 - 0.0).abs() < 1e-6, "100+ = no penalty: {}", p100);
assert!((p500 - 0.0).abs() < 1e-6, "500 = no penalty: {}", p500);
}
#[test]
fn test_trade_insufficiency_no_flat_plateau() {
// The whole point: different trade counts → different penalties
let penalties: Vec<f64> = (0..20).map(|t| calculate_trade_insufficiency_penalty(t)).collect();
for i in 0..19 {
assert!(
penalties[i] > penalties[i + 1] || (penalties[i] - penalties[i + 1]).abs() < 1e-6,
"penalty[{}]={} should be >= penalty[{}]={}",
i, penalties[i], i + 1, penalties[i + 1]
);
}
// Verify no two adjacent values are the same (no plateaus in 0-10 range)
for i in 0..9 {
assert!(
(penalties[i] - penalties[i + 1]).abs() > 0.01,
"PLATEAU at trades={}: penalty[{}]={:.4} == penalty[{}]={:.4}",
i, i, penalties[i], i + 1, penalties[i + 1]
);
}
}
```
**Step 2: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib hyperopt::adapters::dqn::tests::test_trade_insufficiency -- --nocapture`
Expected: all 4 tests pass
**Step 3: Commit**
```bash
git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "test(ml): add unit tests for trade insufficiency penalty gradient"
```
---
### Task 5: Wire factored eval into hyperopt backtest loop
**Files:**
- Modify: `crates/ml/src/hyperopt/adapters/dqn.rs:2764-2790` (backtest loop)
**Step 1: Replace legacy action collapse with factored eval**
Replace lines 2772-2788:
```rust
let factored = crate::dqn::FactoredAction::from_index(action_idx)?;
let legacy = factored.to_legacy_action();
let action = match legacy {
crate::dqn::TradingAction::Buy => Action::Buy,
crate::dqn::TradingAction::Sell => Action::Sell,
crate::dqn::TradingAction::Hold => Action::Hold,
};
let bar = OHLCVBarF32 {
timestamp: bar_idx as i64,
open: close,
high: close,
low: close,
close,
volume: 0.0,
};
engine.process_bar(bar_idx, &bar, action);
ohlcv_bars.push(bar);
```
With:
```rust
let factored = crate::dqn::FactoredAction::from_index(action_idx)?;
let bar = OHLCVBarF32 {
timestamp: bar_idx as i64,
open: close,
high: close,
low: close,
close,
volume: 0.0,
};
engine.process_bar_factored(bar_idx, &bar, &factored);
ohlcv_bars.push(bar);
```
**Step 2: Replace `close_position` with `close_factored_position` at backtest end**
Replace line 2818 (`engine.close_position(ohlcv_bars.len() - 1, last_bar);`) with:
```rust
engine.close_factored_position(ohlcv_bars.len() - 1, last_bar);
```
**Step 3: Run check**
Run: `SQLX_OFFLINE=true cargo check -p ml`
Expected: compiles with 0 errors
**Step 4: Run full test suite**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib -- --nocapture 2>&1 | tail -5`
Expected: all tests pass (2500+)
**Step 5: Run clippy**
Run: `SQLX_OFFLINE=true cargo clippy -p ml --lib -- -D warnings 2>&1 | tail -5`
Expected: 0 warnings
**Step 6: Commit**
```bash
git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "feat(ml): wire factored eval into hyperopt backtest loop
Replace legacy Buy/Sell/Hold collapse with exposure-aware evaluation.
Long100→Long50 now generates a partial-close trade instead of being
a no-op. Combined with graduated trade penalty, PSO now gets gradient
signal across the entire 26D search space."
```
---
### Task 6: Verify full workspace builds and run integration tests
**Files:**
- No files modified (verification only)
**Step 1: Full workspace check**
Run: `SQLX_OFFLINE=true cargo check --workspace`
Expected: compiles with 0 errors
**Step 2: Run ml lib tests**
Run: `SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -3`
Expected: `test result: ok. 2500+ passed; 0 failed`
**Step 3: Run clippy on workspace**
Run: `SQLX_OFFLINE=true cargo clippy --workspace -- -D warnings 2>&1 | tail -5`
Expected: 0 errors, 0 warnings
**Step 4: Commit (no changes, verification only)**
No commit needed — this is verification only.

BIN
fxhnt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB