# Mixed-Type Strategy Chart Pattern

When a single chart needs to compare strategies from DIFFERENT signal families
(MA percentile + volatility target + ROC momentum + Bollinger breakout),
use a dispatcher pattern instead of forcing all strategies into one engine.

## Architecture (generate_gold_strategy_chart.py)

Each strategy type has its own `run_*()` function returning `(nav_arr, pos_arr, dr_arr)`:

- `run_ma_strategy(bt, cfg)` - delegates to `generate_sector_strategy_charts.backtest()` (original engine)
- `run_vol_target(bt, cfg)` - self-implemented, T+1, position = min(max_pos, target_vol/vol)
- `run_roc(bt, cfg)` - self-implemented, binary: ROC > threshold = 100% else 0%
- `run_bb_breakout(bt, cfg)` - self-implemented, close > upper_band = 100%, close < lower_band = 0%

Dispatcher in main():
```python
for s in strat_defs:
    if s['type'] == 'ma':       nav, pos, dr = run_ma_strategy(bt, s)
    elif s['type'] == 'vol_target': nav, pos, dr = run_vol_target(bt, s)
    elif s['type'] == 'roc':    nav, pos, dr = run_roc(bt, s)
    elif s['type'] == 'bb_breakout': nav, pos, dr = run_bb_breakout(bt, s)
```

## Panel Narrative Consistency (USER REQUIREMENT)

User explicitly requires new strategy charts to follow the SAME narrative logic
as existing A-share strategy charts ("图的叙事逻辑尽量保持一致"). When building
a new index's strategy chart, mirror generate_sector_strategy_charts.py panel-by-panel:

**Panel order**: 0=总览表 → 1=NAV → 2=仓位 → 3=信号A → 4=信号B → 5=回撤 → (optional ETF panels)
- 收盘价+均线 is NOT panel 1 — it goes LAST (or is the signal-B fallback when no dynamic strategies)

**Per-panel consistency requirements**:
- NAV: BH dashed + spread_offsets labels + legend labels include key params
  (e.g. "宽买稳卖 MA250 买50/卖85", "ROC动量 ROC30>-2%", "BB突破 25日2.5σ突破")
- 仓位: red/green axhspan zones (0-20 green, 85-100 red), percent scale (-5..105), offset-points end labels
- 信号A: only percentile-type signals, red/green background zones (0-50/75-100),
  dashed threshold lines + text labels at left ("策略名 买X%") + end-value annotations
- 信号B: dynamic-scaled buy/sell lines OR non-MA signals (ROC line + threshold dashed,
  BB upper/lower/mid bands) + close price. Never leave non-MA strategy signals unvisualized.
- 回撤: triangle markers ('v') at each strategy's max-DD date + arrow annotations,
  NOT just end-of-line labels
- All panels: same x-axis range (set_xlim to x_end), MonthLocator(2), rotation=30

**History**: gold chart initially used a different order (price first, no red/green zones,
no triangles, no signal-B for ROC/BB/vol strategies) and user flagged it twice:
"黄金策略图和其他策略图相比少了些东西" then "图的叙事逻辑尽量保持一致".
Later the user also dropped 收盘价与均线 panel entirely ("这个子图没用去掉") once 信号B
showed close price, and asked for ROC momentum on a RIGHT y-axis. Final gold layout:
总览表→NAV→仓位→信号A→信号B→回撤→ETF监测3面板 (8 panels, figsize (20,38)).

**信号B with mixed units → twinx right axis**: When 信号B combines price-scale series
(close, BB bands, ~588-945) with percent-scale series (ROC momentum, ~-20..+30), plot
price/bands on the LEFT axis and ROC on `ax4_twin = ax4.twinx()`. Move the ROC threshold
dashed line and end-value annotation to the twin axis too. Merge legends:
`h4,l4 = ax4.get_legend_handles_labels(); h4r,l4r = ax4_twin.get_legend_handles_labels(); ax4.legend(h4+h4r, l4+l4r, ...)`.
Color the right axis to match the ROC series (`tick_params(axis='y', labelcolor=...)`).

**ETF monitoring panels on strategy charts**: The strategy chart itself can carry the
ETF-monitor triplet (代表ETF收盘价+信号圆 → 5日/10日百分位 → 净流入堆叠+总净资产右轴),
identical to standalone monitor charts. Load `etf_<group>_combined.csv`, compute pct5/pct10
on the FULL history THEN truncate to backtest start (rank must see full history), pick
代表ETF = largest total_asset at last row, FLOW_TO_SIZE=6.0 for strategy charts. Drop the
incomplete last row when >50% of change_ columns are 0/NaN. GridSpec adds 3 panels with
height_ratios [1.8, 1.5, 1.8]; total height ≈ (20, 38) at dpi=150.

## Whitespace measurement (choose tight vs manual layout)

Before fixing any "留白" complaint, MEASURE with PIL instead of guessing:
```python
img = (np.array(Image.open(f).convert('RGB')) > 245).all(axis=2)
col_allwhite = img.all(axis=0); row_allwhite = img.all(axis=1)
left = np.where(~col_allwhite)[0][0]; right = np.where(~col_allwhite)[0][-1]
```
Outer side whitespace → fix by adding `bbox_inches='tight'` to savefig (gold: 9.2%→0.5%).
Internal gap between panels → fix via figsize/subplots_adjust, DROP tight.
Compare against a reference chart the user considers correct (e.g. strategy_980022.png)
with the same measurement.

## Critical Implementation Details

1. **T+1 execution**: `prev_pos = positions[-1]; nav *= (1 + prev_pos * rets[i])`
   - Update position AFTER computing daily return with previous position
   - Original MA engine uses `strat_ret = ret * pos.shift(1); nav = (1+strat_ret).cumprod()`

2. **MA strategy must call original engine**: Don't reimplement the MA backtest inline.
   The original engine has subtle ordering (sell-before-buy, clip, dd_threshold, hard_sell)
   that's easy to get wrong. Import and call `generate_sector_strategy_charts.backtest()`.

3. **bt must use DatetimeIndex**: `bt = df.set_index('trade_date')` not `reset_index(drop=True)`.
   The original engine uses `.loc[]` which needs the datetime index.

4. **percentileofscore**: Use `scipy.stats.percentileofscore`, NOT `np.searchsorted`.
   0.22 absolute difference in percentile → 18% difference in backtest return.

## Chart annotations

5. **Chart annotations**: Every panel needs:
   - End-of-data value labels (using `mdates.date2num(last_dt) + 15` as x offset)
   - `set_xlim(data_min, data_max + 30 days)` for label space
   - `spread_offsets()` for NAV label anti-overlap
   - Color-coded annotations matching strategy colors

6. **⚠️ spread_offsets returns POINTS, not data coordinates**: `spread_offsets()` from `chart_utils.py` returns y-offsets in **display points** (e.g., -14 to -60). These are designed for use with `textcoords='offset points'` in `ax.annotate()`. Do NOT add them to data-coordinate y-values (`xytext=(label_x, nav[-1] + nav_offsets[i])`) — this mixes units and causes massive label displacement (labels pushed dozens of data-units below their lines). Correct usage:
   ```python
   # CORRECT: offset points mode
   ax.annotate(text, xy=(last_dt, nav[-1]),
               xytext=(8, nav_offsets[i]), textcoords='offset points', ...)
   # WRONG: adding points to data coordinates
   ax.annotate(text, xy=(last_dt, nav[-1]),
               xytext=(label_x, nav[-1] + nav_offsets[i]), ...)  # units mismatch!
   ```
   This bug manifested as NAV labels being pushed far below their data lines on the gold strategy chart.

## Strategy Search Pattern (delegate_task)

For exploring non-MA strategies on a new asset:
1. Dispatch subagent with data path, BT_START, BH metrics, strategy types to search
2. Subagent writes temp scripts, runs parameter sweeps with `/usr/bin/python3`
3. Subagent returns Top 3 per strategy type
4. Select best 4-5 across types for the final chart

Key finding (gold 2024-2026): Trend-following (ROC/BB/DC) dominates in strong-trend
markets. Mean-reversion (RSI) completely fails. All trend strategies had identical
-17.1% max drawdown, suggesting a structural floor for this period.
