"""Single-market kinds + their causal rolling-extremum helpers."""
from __future__ import annotations

import numpy as np

from fxhnt.domain.models import AssetClass, Market, PriceSeries


def _prices(close, high=None, low=None) -> PriceSeries:
    c = np.asarray(close, float)
    return PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE),
                       dates=tuple(str(i) for i in range(len(c))), close=c,
                       high=None if high is None else np.asarray(high, float),
                       low=None if low is None else np.asarray(low, float))


def test_rolling_max_min_are_causal() -> None:
    from fxhnt.domain.strategies.base import rolling_max, rolling_min
    x = np.array([1.0, 3.0, 2.0, 5.0, 4.0])
    rmax = rolling_max(x, 3)
    rmin = rolling_min(x, 3)
    # window ends at t (inclusive), shrinks at the start, never looks ahead
    assert list(rmax) == [1.0, 3.0, 3.0, 5.0, 5.0]
    assert list(rmin) == [1.0, 1.0, 1.0, 2.0, 2.0]


def test_breakout_goes_long_on_upside_break_no_lookahead() -> None:
    from fxhnt.domain.strategies.breakout import Breakout
    # flat at 100 for the window, then a clean upside breakout
    close = np.array([100.0] * 25 + [110.0, 111.0, 112.0])
    pos = Breakout().positions(_prices(close), {"window": 20})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    assert pos[-1] == 1.0                       # held long after the break
    # lookahead check: altering ONLY the last bar must not change any earlier position
    close2 = close.copy(); close2[-1] = 999.0
    pos2 = Breakout().positions(_prices(close2), {"window": 20})
    assert np.array_equal(pos[:-1], pos2[:-1])


def test_breakout_long_only_by_default_short_when_enabled() -> None:
    from fxhnt.domain.strategies.breakout import Breakout
    close = np.array([100.0] * 25 + [110.0] * 5 + [90.0, 89.0, 88.0])
    long_only = Breakout().positions(_prices(close), {"window": 20})
    assert long_only.min() == 0.0              # downside break -> flat (no short)
    with_short = Breakout().positions(_prices(close), {"window": 20, "long_short": 1.0})
    assert with_short.min() == -1.0            # downside break -> short


def test_seasonal_signal_is_causal_zero_in_warmup() -> None:
    from fxhnt.domain.seasonality import seasonal_signal
    dates = tuple(f"{y}-{m:02d}-01" for y in (2020, 2021) for m in range(1, 13))
    close = np.cumprod(1.0 + np.full(len(dates), 0.01)) * 100.0
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    _, sig = seasonal_signal(ps, min_prior_years=3)
    assert np.allclose(sig, 0.0)


def _monthly_prices(years, jan_drift, other_drift):
    """Daily-ish series: every January gets jan_drift per step, other months other_drift."""
    import numpy as np
    dates, rets = [], []
    for y in years:
        for m in range(1, 13):
            for day in range(1, 21):
                dates.append(f"{y}-{m:02d}-{day:02d}")
                rets.append(jan_drift if m == 1 else other_drift)
    close = 100.0 * np.cumprod(1.0 + np.asarray(rets))
    return tuple(dates), close


def test_seasonality_goes_long_in_a_historically_positive_month() -> None:
    from fxhnt.domain.strategies.seasonality import Seasonality
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    dates, close = _monthly_prices(range(2016, 2022), jan_drift=0.01, other_drift=0.0)
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    pos = Seasonality().positions(ps, {"min_prior_years": 3})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    jan = np.array([d[5:7] == "01" for d in dates])
    later_jan = jan & (np.array([int(d[:4]) for d in dates]) >= 2020)  # past warmup
    assert pos[later_jan].mean() > 0.9          # long in January once >=3 prior Januaries exist


def test_seasonality_negative_control_is_flat_on_noise() -> None:
    from fxhnt.domain.strategies.seasonality import Seasonality
    from fxhnt.domain.models import AssetClass, Market, PriceSeries
    rng = np.random.default_rng(0)
    dates, _ = _monthly_prices(range(2010, 2022), jan_drift=0.0, other_drift=0.0)
    close = 100.0 * np.cumprod(1.0 + rng.normal(0, 0.01, len(dates)))  # i.i.d., no season
    ps = PriceSeries(market=Market(symbol="X", asset_class=AssetClass.FUTURE), dates=dates,
                     close=np.asarray(close, float))
    pos = Seasonality().positions(ps, {"min_prior_years": 3})
    assert abs(pos.mean()) < 0.2                # no structural season -> near-flat on average


def test_vol_managed_scales_down_when_vol_spikes() -> None:
    from fxhnt.domain.strategies.vol_managed_momentum import VolManagedMomentum
    rng = np.random.default_rng(1)
    calm = 0.0005 + rng.normal(0, 0.003, 200)      # low-vol uptrend
    wild = 0.0005 + rng.normal(0, 0.030, 200)      # same drift, 10x vol
    close = 100.0 * np.cumprod(1.0 + np.concatenate([calm, wild]))
    pos = VolManagedMomentum().positions(_prices(close),
                                         {"window": 100, "vol_window": 20, "target_vol": 0.15})
    assert pos.min() >= -1.0 and pos.max() <= 1.0
    calm_exposure = np.abs(pos[150:200]).mean()
    wild_exposure = np.abs(pos[350:400]).mean()
    assert wild_exposure < calm_exposure           # de-risks in the high-vol regime


def test_vol_managed_no_lookahead() -> None:
    from fxhnt.domain.strategies.vol_managed_momentum import VolManagedMomentum
    rng = np.random.default_rng(2)
    close = 100.0 * np.cumprod(1.0 + (0.0005 + rng.normal(0, 0.01, 300)))
    pos = VolManagedMomentum().positions(_prices(close), {})
    close2 = close.copy(); close2[-1] = 500.0
    pos2 = VolManagedMomentum().positions(_prices(close2), {})
    assert np.array_equal(pos[:-1], pos2[:-1])     # last bar cannot affect earlier positions
