"""
entry_engine.py

Combines premarket setup + real-time opening behavior into a single
ENTRY / NO ENTRY decision with a documented reason string, per the
project's explicit example:

    Candidate ranked #2 premarket
    + Opening volume expansion
    + Price above VWAP
    + VWAP rising
    + Higher lows
    + Breaks premarket resistance
    + Breakout holds
    + Fresh intraday health check confirms tradeable  [BUGFIX 2026-08-18]
    = ENTRY

Never enters purely on premarket rank. Every check here operates on
live intraday bars pulled from stream.py's buffer.

[BUGFIX 2026-08-18] Price-above-VWAP / VWAP-rising / volume-expansion
are necessary but NOT sufficient -- they can all be momentarily true on
a few noisy ticks while the broader structure is already deteriorating.
The caller (monitor.py) is required to compute a fresh
intraday_health.HealthReading from the SAME live_bars being evaluated
here (not from the periodically-persisted state/intraday_health.json,
which can be stale by up to intraday_health.eval_interval_seconds) and
pass it in as health_reading. See evaluate_entry()'s docstring.
"""

from dataclasses import dataclass, field
from config_loader import get_config
from logger_setup import get_logger
from datetime import time as dt_time

from indicators import (
    vwap, vwap_slope, is_higher_highs_higher_lows, breakout_confirmed,
    pullback_then_continuation, spread_pct, extension_from_vwap_pct,
    relative_volume, rsi, is_extended_downtrend, distance_from_high_pct,
)
import market_time

log = get_logger("entry_engine")


def _health_floor_for_state(cfg: dict, raw_state: str) -> float:
    """
    [FEATURE 2026-08-25] Backward-compatible resolution order:
      1. entry.min_health_score_by_state[raw_state], if that dict and key exist
      2. entry.min_health_score (the old flat single-number config), if set
      3. 0 (no floor)
    2026-08-24 session review: WATCH-classified entries ranged score=48
    to score=92 and were all treated identically by the flat floor,
    even though WATCH is explicitly the weaker of the two entry-eligible
    states. Splitting the floor per-state lets WATCH require a
    meaningfully higher score than HEALTHY without touching the
    categorical raw_state gate itself.
    """
    by_state = cfg.get("min_health_score_by_state")
    if isinstance(by_state, dict) and raw_state in by_state:
        return by_state[raw_state]
    return cfg.get("min_health_score", 0)


def _below_state_dependent_floor(cfg: dict, health_reading) -> bool:
    if not cfg.get("require_min_health_score", False):
        return False
    floor = _health_floor_for_state(cfg, health_reading.raw_state)
    return health_reading.health_score < floor


def _apply_time_window_overrides(cfg: dict) -> dict:
    """
    [FEATURE 2026-08-27] Lets entry.time_based_overrides define different
    entry-config values for different parts of the trading day -- e.g.
    stricter thresholds right at the volatile open, looser mid-day,
    something else again in the afternoon. Disabled unless
    entry.time_based_overrides.enabled is true, in which case:

      1. Find the window whose [start, end) contains the current time
         (America/New_York, matching every other schedule check in this
         project -- see market_time.now_et()).
      2. Return a NEW dict: the base cfg with that window's "overrides"
         keys shallow-merged on top. Only keys present in a window's
         overrides are changed; everything else falls through to the
         base config unchanged.
      3. If no window matches the current time (e.g. gaps between
         windows, or outside regular hours), the unmodified base cfg is
         used -- a config typo or gap fails safe to normal behavior,
         not to an undefined state.

    Windows are checked in the order they appear in config.json; the
    first match wins, so don't define overlapping windows unless that
    ordering-dependent behavior is actually what's intended.
    """
    windows_cfg = cfg.get("time_based_overrides", {})
    if not windows_cfg.get("enabled", False):
        return cfg

    now = market_time.now_et().time()
    for window in windows_cfg.get("windows", []):
        try:
            start = dt_time.fromisoformat(window["start"])
            end = dt_time.fromisoformat(window["end"])
        except (KeyError, ValueError):
            continue  # malformed window entry -- skip it, don't crash the entry decision
        if start <= now < end:
            merged = dict(cfg)
            merged.update(window.get("overrides", {}))
            return merged

    return cfg


@dataclass
class EntryDecision:
    symbol: str
    should_enter: bool
    confirmation_score: float
    reasons_for: list = field(default_factory=list)
    reasons_against: list = field(default_factory=list)


def evaluate_entry(symbol: str, premarket_result: dict, live_bars: list,
                    quote: tuple, opening_baseline_volume: float,
                    health_reading=None, is_reentry: bool = False,
                    cfg_override: dict = None) -> EntryDecision:
    """
    premarket_result: the scored dict from scorer.score_premarket_candidate
                       (gives us pm_high, premarket rank/score context)
    live_bars: list of 1-min bars since market open, oldest first
    quote: (bid, ask, timestamp) or None
    opening_baseline_volume: expected "normal" opening volume for
                              comparison, e.g. premarket total volume,
                              used to detect "opening volume expansion"
    cfg_override: [SIMULATION-ONLY 2026-09-04] optional dict shallow-merged
        on top of the base entry config (same mechanism as
        _apply_time_window_overrides' per-window overrides, applied AFTER
        it so this wins on any overlapping key) -- lets a caller evaluate
        this same, unchanged confirmation logic against a different rule
        set without duplicating it. Added for regime_strategies.py /
        simulate_regime_strategies.py so each regime_detector.py regime
        can carry its own independent entry rules (see config.json's
        regime_strategies section) while every real check above stays
        the single source of truth. None (the default) preserves prior
        behavior exactly -- monitor.py's live call site is unaffected.
    health_reading: an intraday_health.HealthReading computed by the
        CALLER, right now, from THESE SAME live_bars. [BUGFIX 2026-08-18]

        This must be freshly computed at decision time, not read from
        state/intraday_health.json (that file is only as current as the
        last periodic intraday_health eval cycle -- up to
        intraday_health.eval_interval_seconds old, and can lag further
        if a cycle was skipped). Confirmed live: two entries on RCAT
        fired using a cached health state that was ~10 minutes stale at
        the moment of entry, because nothing forced a fresh check at the
        entry decision itself.

        Health is a hard disqualifier here, not just another vote in
        the confirmation-score tally -- "price above VWAP, VWAP rising,
        opening volume expansion" can all be true for a few ticks on
        pure noise while the broader structure (price/VWAP slope,
        higher-highs/higher-lows, volume trend) is already fading; that
        combination is exactly what health_reading evaluates. No
        combination of the other checks can outvote a failed health
        read, matching how the spread/extension disqualifiers already
        work below.
    is_reentry: True if this symbol already has a closed position
        earlier THIS SESSION (computed by the caller via
        position_manager.has_closed_position_today()). [FEATURE
        2026-08-27] Replaces the earlier time-based
        same_symbol_reentry_cooldown_minutes approach -- rather than a
        fixed wait, a re-entry into a symbol the bot already traded
        (and exited, win or loss) today must clear
        entry.reentry_min_confirmation_score (100% by default) instead
        of the normal entry.min_confirmation_score. See
        require_full_confirmation_on_reentry in config.json.
    """
    cfg = get_config()["entry"]
    cfg = _apply_time_window_overrides(cfg)
    if cfg_override:
        cfg = {**cfg, **cfg_override}
    reasons_for = []
    reasons_against = []

    if len(live_bars) < 2:
        return EntryDecision(symbol, False, 0.0, [], ["insufficient live bars since open"])

    current_price = live_bars[-1]["c"]
    pm_high = premarket_result.get("pm_high", 0)
    resistance = pm_high  # premarket high is the primary resistance reference

    v_wap = vwap(live_bars)
    v_slope = vwap_slope(live_bars, lookback=min(cfg["vwap_rising_lookback_bars"], len(live_bars) - 1))
    opening_volume = sum(b["v"] for b in live_bars)
    vol_expansion_ratio = relative_volume(opening_volume, opening_baseline_volume) if opening_baseline_volume else 1.0

    checks_passed = 0
    checks_total = 0
    disqualified = False

    # 1. Price above VWAP
    if cfg["require_price_above_vwap"]:
        checks_total += 1
        if current_price > v_wap:
            checks_passed += 1
            reasons_for.append(f"price ${current_price:.2f} above VWAP ${v_wap:.2f}")
        else:
            reasons_against.append(f"price ${current_price:.2f} below VWAP ${v_wap:.2f}")

    # 2. VWAP rising
    if cfg["require_vwap_rising"]:
        checks_total += 1
        if v_slope > 0:
            checks_passed += 1
            reasons_for.append("VWAP rising")
        else:
            reasons_against.append("VWAP flat or declining")

    # 3. Opening volume expansion
    if cfg["require_opening_volume_expansion"]:
        checks_total += 1
        if vol_expansion_ratio >= cfg["opening_volume_expansion_ratio"]:
            checks_passed += 1
            reasons_for.append(f"opening volume expansion {vol_expansion_ratio:.2f}x")
        else:
            reasons_against.append(f"opening volume expansion insufficient ({vol_expansion_ratio:.2f}x)")

    # 4. Higher lows structure
    if cfg["require_higher_lows"]:
        checks_total += 1
        if is_higher_highs_higher_lows(live_bars, lookback=min(cfg["higher_lows_lookback_bars"], len(live_bars))):
            checks_passed += 1
            reasons_for.append("higher lows structure intact")
        else:
            reasons_against.append("no clean higher-lows structure yet")

    # 5. Breakout of premarket high / resistance, confirmed to hold
    breakout_ok = False
    if cfg["require_breakout_of_pm_high_or_resistance"] and resistance > 0:
        checks_total += 1
        breakout_ok = breakout_confirmed(
            live_bars, resistance, cfg["breakout_buffer_pct"], cfg["breakout_hold_bars"]
        )
        if breakout_ok:
            checks_passed += 1
            reasons_for.append(f"breakout of resistance ${resistance:.2f} held for "
                                f"{cfg['breakout_hold_bars']} bars")
        else:
            # allow the alternative: pullback-then-continuation entry
            if cfg["allow_pullback_continuation_entry"] and pullback_then_continuation(
                live_bars, cfg["pullback_max_retrace_pct"]
            ):
                checks_passed += 1
                breakout_ok = True
                reasons_for.append("healthy pullback followed by continuation")
            else:
                reasons_against.append(f"has not confirmed breakout of ${resistance:.2f}")

    # 6. [FEATURE 2026-08-25] Momentum not fading -- previously this was
    # ONLY visible via the health-classification corroboration rule
    # (momentum_fading + one other weak signal -> STALE), which meant
    # ISOLATED negative momentum with everything else looking clean
    # never cost anything. 2026-08-24 session review flagged NU, MARA,
    # and others as real losses that passed cleanly despite negative
    # momentum at entry. This gives momentum its own vote, independent
    # of whether health's stricter corroboration threshold happens to
    # fire. Only evaluated when a fresh health_reading is available
    # (same data source, just asked a second, more direct question of it).
    # [FEATURE 2026-08-25, revised] Two independent modes, both opt-in
    # via config, neither on by default:
    #
    #   require_momentum_not_fading (soft vote, unchanged from before):
    #     contributes one vote among checks_total. Verified against real
    #     2026-08-24 data that this ALONE never has enough weight to
    #     flip a decision -- losing this one vote among 7 total checks
    #     only ever dropped the worst case to 71.4%, still above the
    #     65% min_confirmation_score floor. Useful as a contributing
    #     signal, not as an actual gate.
    #
    #   require_momentum_not_fading_strict (NEW -- hard disqualifier):
    #     negative momentum blocks entry outright, same tier as the
    #     VWAP-extension and fresh-health checks below, regardless of
    #     how many other checks pass. This is what actually makes
    #     momentum "part of the entry decision" rather than one vote
    #     that can always be outvoted. When this is enabled it takes
    #     over entirely for this signal -- the soft vote above is
    #     skipped so momentum isn't counted twice (once as a vote, once
    #     as a disqualifier) and confirmation_score isn't misleadingly
    #     diluted by a check that was actually unconditional.
    momentum_strict = cfg.get("require_momentum_not_fading_strict", False)
    if momentum_strict and health_reading is not None:
        if health_reading.momentum_slope_class == "negative":
            disqualified = True
            reasons_against.append(
                f"momentum fading (momentum_slope=negative) -- hard disqualifier, "
                f"require_momentum_not_fading_strict is on"
            )
        else:
            reasons_for.append(f"momentum {health_reading.momentum_slope_class}")
    elif cfg.get("require_momentum_not_fading", False) and health_reading is not None:
        checks_total += 1
        if health_reading.momentum_slope_class != "negative":
            checks_passed += 1
            reasons_for.append(f"momentum {health_reading.momentum_slope_class}")
        else:
            reasons_against.append("momentum fading (momentum_slope=negative)")

    # 7. [FEATURE 2026-08-25] Volume not declining -- same rationale as
    # momentum above. SBET's 2026-08-24 entries showed volume_declining
    # =True passing cleanly because require_opening_volume_expansion
    # only measures CUMULATIVE magnitude since open, not the current
    # trend; a stock can clear that bar on an early spike while volume
    # is actively fading right now. volume_declining is already
    # computed by compute_health() (and logged since 2026-08-21) -- this
    # just gives it its own vote instead of leaving it buried inside
    # health's classification, same pattern as momentum above.
    if cfg.get("require_volume_not_declining", False) and health_reading is not None:
        checks_total += 1
        if not health_reading.flags.get("volume_declining", False):
            checks_passed += 1
            reasons_for.append("volume not declining")
        else:
            reasons_against.append("volume declining")

    # --- Disqualifiers (any one of these blocks entry regardless of score) ---

    # [BUGFIX 2026-08-18] Fresh intraday-health gate -- required, hard
    # disqualifier. See the health_reading parameter docstring above for
    # why this can't be optional or soft-weighted.
    #
    # [FEATURE 2026-08-20] momentum_slope_class added to both log strings
    # below, purely additive -- no change to should_enter/disqualified
    # logic here (that's intraday_health.compute_health()'s job, see the
    # 2026-08-20 raw_state classification change there). This was
    # previously computed on every HealthReading but never surfaced in
    # the CONFIRMATION log line, which made it impossible to retroactively
    # check what momentum was doing at real past entries (confirmed gap:
    # QUBT's two 2026-08-20 losing entries could not be analyzed for this
    # after the fact because the value was never logged in the first
    # place). Going forward every CONFIRMATION line carries it.
    #
    # [FEATURE 2026-08-21] volume_declining, lower_hl, and price_above_vwap
    # added the same way. Unlike momentum_slope, these three were already
    # load-bearing in compute_health()'s raw_state classification before
    # today (they gate the HEALTHY/UNHEALTHY branches, and are exactly the
    # corroboration signals the 2026-08-20 momentum-fading STALE branch
    # checks) -- so they were already silently deciding entries every day,
    # just invisibly. This doesn't change what gets decided, only what's
    # visible after the fact: without this, there was no way to tell
    # WHY a momentum-fading reading did or didn't get corroborated into
    # STALE for any specific past entry.
    #
    # [FEATURE 2026-08-22] New, separate numeric gate on top of the
    # existing categorical one: raw_state in (HEALTHY, WATCH) says
    # nothing about HOW healthy within that bucket -- 2026-08-21 real
    # data showed WATCH-classified entries ranging from score=48 to
    # score=92, treated identically by the categorical check alone (see
    # the min_health_score discussion). This adds a configurable floor
    # underneath the categorical check. Defaults to 50 in config.json,
    # deliberately low enough not to retroactively contradict any of
    # 2026-08-21's real winners (lowest winning score that day was 68) --
    # tune min_health_score directly in config.json to tighten it.
    if cfg.get("require_fresh_health_check", True):
        if health_reading is None:
            disqualified = True
            reasons_against.append("no fresh health reading available at entry time")
        elif health_reading.raw_state not in ("HEALTHY", "WATCH"):
            disqualified = True
            reasons_against.append(
                f"fresh health check failed (state={health_reading.raw_state}, "
                f"score={health_reading.health_score:.0f}, "
                f"price_slope={health_reading.price_slope_class}, "
                f"vwap_slope={health_reading.vwap_slope_class}, "
                f"momentum_slope={health_reading.momentum_slope_class}, "
                f"volume_declining={health_reading.flags.get('volume_declining')}, "
                f"lower_hl={health_reading.flags.get('lower_highs_lower_lows')}, "
                f"price_above_vwap={health_reading.flags.get('price_above_vwap')})"
            )
        elif _below_state_dependent_floor(cfg, health_reading):
            disqualified = True
            floor = _health_floor_for_state(cfg, health_reading.raw_state)
            reasons_against.append(
                f"fresh health score too low (state={health_reading.raw_state}, "
                f"score={health_reading.health_score:.0f} < "
                f"min_health_score[{health_reading.raw_state}]={floor}, "
                f"price_slope={health_reading.price_slope_class}, "
                f"vwap_slope={health_reading.vwap_slope_class}, "
                f"momentum_slope={health_reading.momentum_slope_class}, "
                f"volume_declining={health_reading.flags.get('volume_declining')}, "
                f"lower_hl={health_reading.flags.get('lower_highs_lower_lows')}, "
                f"price_above_vwap={health_reading.flags.get('price_above_vwap')})"
            )
        else:
            reasons_for.append(
                f"fresh health {health_reading.raw_state} "
                f"(score={health_reading.health_score:.0f}, "
                f"price_slope={health_reading.price_slope_class}, "
                f"vwap_slope={health_reading.vwap_slope_class}, "
                f"momentum_slope={health_reading.momentum_slope_class}, "
                f"volume_declining={health_reading.flags.get('volume_declining')}, "
                f"lower_hl={health_reading.flags.get('lower_highs_lower_lows')}, "
                f"price_above_vwap={health_reading.flags.get('price_above_vwap')})"
            )

    if quote:
        bid, ask, _ = quote
        spr = spread_pct(bid, ask)
        if spr > cfg["max_spread_pct_entry"]:
            disqualified = True
            reasons_against.append(f"spread too wide ({spr:.2f}%)")

    extension = extension_from_vwap_pct(current_price, v_wap)
    if extension > cfg["max_extension_from_vwap_pct"]:
        disqualified = True
        reasons_against.append(f"price extended {extension:.2f}% above VWAP (max "
                                f"{cfg['max_extension_from_vwap_pct']}%)")

    # [FEATURE 2026-08-25] Minimum separation floor -- the existing check
    # above only ever had a ceiling. 2026-08-24 review flagged SBET
    # entering with razor-thin VWAP separation (technically "above,"
    # with essentially no conviction behind it) as a recurring pattern.
    # Only applied when min_extension_from_vwap_pct is explicitly set
    # (>0) so this is opt-in, not a silent behavior change.
    min_extension = cfg.get("min_extension_from_vwap_pct", 0)
    if min_extension > 0 and extension < min_extension:
        disqualified = True
        reasons_against.append(f"price only {extension:.2f}% above VWAP (min "
                                f"{min_extension}%) -- insufficient separation")

    # [FEATURE 2026-08-26] RSI(14) overbought ceiling -- a different
    # measurement from the VWAP-extension checks above: extension_pct
    # measures distance from a short-window volume-weighted average,
    # while RSI normalizes the balance of gains vs. losses over a
    # longer period into a bounded 0-100 scale -- closer to "how
    # stretched is this move," independent of VWAP. Opt-in, disabled
    # unless require_rsi_not_overbought is explicitly true, and fully
    # tunable/disable-able live via config.json -- no redeploy needed
    # to turn this off or retune the threshold.
    if cfg.get("require_rsi_not_overbought", False):
        rsi_period = cfg.get("rsi_period", 14)
        rsi_value = rsi(live_bars, period=rsi_period)
        max_rsi = cfg.get("max_rsi_at_entry", 70)
        if rsi_value > max_rsi:
            disqualified = True
            reasons_against.append(f"RSI({rsi_period}) overbought ({rsi_value:.1f} > {max_rsi})")
        else:
            reasons_for.append(f"RSI({rsi_period})={rsi_value:.1f}")

    # [FEATURE 2026-09-05, SIMULATION-ONLY -- off by default] Don't chase a
    # fading spike. Added specifically for regime_strategies.py's
    # REGIME_7_INTRADAY_SPIKE_NO_GAP_WHIPSAW override, after CHPT's real
    # 2026-09-04 entry (-$54.87, the day's worst trade) confirmed cleanly
    # on every existing check -- price above VWAP, VWAP rising, breakout
    # of $9.50 held, momentum positive, RSI a neutral 50 (too little
    # history yet for a real reading) -- despite entering at $9.83 already
    # riding the reversal off a $10.20 spike just one minute earlier. None
    # of the existing checks ask "how far off its own recent high is this,
    # right now" -- max_extension_from_vwap_pct measures distance from a
    # volume-weighted average, not from a recent print, so a stock that
    # spiked and is already falling can still read as "not extended" by
    # that measure alone. Disabled (require_no_recent_spike_extension:
    # false) at the base entry-config level, same pattern as
    # require_rsi_not_overbought/require_no_extended_downtrend when they
    # were added -- a regime that needs it turns it on via cfg_override.
    if cfg.get("require_no_recent_spike_extension", False):
        lookback = min(cfg.get("recent_spike_lookback_bars", 10), len(live_bars))
        recent_high = max(b["h"] for b in live_bars[-lookback:])
        pullback_pct = distance_from_high_pct(current_price, recent_high)
        max_pullback = cfg.get("max_pullback_from_recent_high_pct", 2.0)
        if pullback_pct > max_pullback:
            disqualified = True
            reasons_against.append(
                f"price already {pullback_pct:.2f}% off its recent high ${recent_high:.2f} "
                f"(max {max_pullback}%) -- looks like a fading spike, not fresh strength"
            )
        else:
            reasons_for.append(f"still within {max_pullback}% of its recent high (not chasing a fading spike)")

    # Fade / structure failure disqualifiers
    if len(live_bars) >= 2:
        recent_closes = [b["c"] for b in live_bars[-3:]]
        if len(recent_closes) >= 2 and recent_closes[-1] < recent_closes[0] * 0.985:
            disqualified = True
            reasons_against.append("price fading immediately after open")

    # [FEATURE 2026-09-03] Extended session downtrend -- disqualifies an
    # entry that's riding a bounce inside a decline that hasn't actually
    # reversed yet. Confirmed gap on ACHR's 2026-09-03 14:01 ET entry:
    # every check above looks at a short 4-8 bar window (vwap_rising_
    # lookback_bars / higher_lows_lookback_bars / intraday_health's
    # slope_lookback_bars), which all read cleanly positive the moment a
    # bounce started, with nothing aware ACHR had gone $5.96 (09:35 ET)
    # -> $5.70 (13:02 ET) -- a real ~4.5% decline over 3.5 hours -- and
    # the $5.79 entry was still 2.85% off that high: a bounce, not a
    # reversal. See indicators.is_extended_downtrend() for the exact
    # definition (a regression fit over ALL bars since open, not a fixed
    # short window, combined with distance from the session high so an
    # entry that's already reclaimed the highs is never penalized for a
    # downtrend that's already over). Requires downtrend_min_bars of
    # history before it evaluates at all, so it can't fire on a fresh
    # candidate a few minutes off the open.
    if cfg.get("require_no_extended_downtrend", True):
        if is_extended_downtrend(
            live_bars,
            min_bars=cfg.get("downtrend_min_bars", 20),
            min_decline_pct=cfg.get("downtrend_min_decline_pct", 1.5),
            recovery_threshold_pct=cfg.get("downtrend_recovery_threshold_pct", 1.5),
        ):
            disqualified = True
            reasons_against.append(
                "session still in a downtrend from its high -- this looks like a "
                "bounce, not a confirmed reversal (require_no_extended_downtrend)"
            )
        else:
            reasons_for.append("no extended session downtrend")

    confirmation_score = (checks_passed / checks_total * 100.0) if checks_total else 0.0

    # [FEATURE 2026-08-27] Score-based re-entry gate, replacing the
    # earlier time-based same_symbol_reentry_cooldown_minutes approach.
    # Real 08-24 review flagged MARA (re-entered the same SECOND as a
    # losing exit) and CLSK (re-entered 8 minutes after a winning exit)
    # as recurring bad patterns; a fixed-minutes wait was one way to
    # address that, but a fixed wait doesn't actually check whether the
    # SETUP itself has improved -- it just delays. This instead demands
    # a materially higher bar of evidence specifically for re-entries:
    # every check clearing cleanly (100% by default), not just enough
    # to clear the normal min_confirmation_score. A fresh symbol never
    # traded today is unaffected -- only symbols with a closed position
    # already on record this session face the stricter bar.
    reentry_blocked = False
    if is_reentry and cfg.get("require_full_confirmation_on_reentry", False):
        reentry_min_score = cfg.get("reentry_min_confirmation_score", 100)
        if confirmation_score < reentry_min_score:
            reentry_blocked = True
            reasons_against.append(
                f"re-entry requires {reentry_min_score}% confirmation, got "
                f"{confirmation_score:.0f}% (symbol already traded this session)"
            )

    should_enter = (
        not disqualified
        and not reentry_blocked
        and confirmation_score >= cfg["min_confirmation_score"]
    )

    decision = EntryDecision(
        symbol=symbol,
        should_enter=should_enter,
        confirmation_score=round(confirmation_score, 1),
        reasons_for=reasons_for,
        reasons_against=reasons_against,
    )

    if should_enter:
        log.info(f"[CONFIRMATION] {symbol} confirmed ({confirmation_score:.0f}%): "
                 f"{'; '.join(reasons_for)}")
    else:
        # [BUGFIX 2026-08-28] logging.log_rejections has existed in
        # config.json since this project's early logging work, with
        # exactly this use case in mind ("genuinely valuable for
        # understanding WHY something didn't trade, not only what
        # did") -- but this specific line was hardcoded to log.debug()
        # regardless of that flag, so with the project's normal
        # logging.level=INFO, rejection reasoning was being computed
        # correctly every single poll cycle and then silently
        # discarded before ever reaching the log file. Confirmed on
        # 2026-08-27's real log: 78 symbols scored HEALTHY/WATCH above
        # 65 that day and only 2 became real trades, but there was no
        # way to see why the other 76 were turned away -- every one of
        # those evaluate_entry() calls DID compute a real
        # reasons_against list, it just never got written anywhere.
        log_rejections = get_config().get("logging", {}).get("log_rejections", False)
        message = (f"[CONFIRMATION] {symbol} not confirmed ({confirmation_score:.0f}%): "
                   f"{'; '.join(reasons_against) if reasons_against else 'no specific reason recorded'}")
        if log_rejections:
            log.info(message)
        else:
            log.debug(message)

    return decision
