diff --git a/apps/backtester/run.py b/apps/backtester/run.py index a139e13..a18958b 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -265,9 +265,13 @@ class BacktestRunner: if drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT and not self._kill_switch_triggered: logger.warning("kill_switch_triggered", date=str(date), drawdown_pct=drawdown_pct) - self._kill_switch_triggered = True - if self.config.risk.backtest_mode == "research": - self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days + if self.config.risk.kill_switch_log_only: + logger.info("kill_switch_log_only_mode", date=str(date)) + # Don't trigger — just observe + else: + self._kill_switch_triggered = True + if self.config.risk.backtest_mode == "research": + self._kill_switch_cooldown_remaining = self.config.risk.kill_switch_cooldown_days # Research mode: reset kill switch after cooldown expires # Reset peak_equity to current equity so drawdown restarts from 0 diff --git a/configs/backtest/defaults.json b/configs/backtest/defaults.json index b2531a3..0c2a1b7 100644 --- a/configs/backtest/defaults.json +++ b/configs/backtest/defaults.json @@ -27,6 +27,7 @@ "stop_atr_multiplier": 3.0, "backtest_mode": "research", "kill_switch_cooldown_days": 20, + "kill_switch_log_only": false, "veto_oneoff_penalty": 0.7, "veto_parse_confidence_min": 0.4, "veto_unknown_direction": true, @@ -44,7 +45,8 @@ "target_1_fraction": 0.5, "trailing_model": "pct_3", "trailing_warmup_days": 2, - "max_holding_days": 10 + "max_holding_days": 10, + "no_follow_through_exit": true }, "reporting": { "write_trade_blotter": true, diff --git a/configs/experiments/earnings_only_v1.json b/configs/experiments/earnings_only_v1.json new file mode 100644 index 0000000..401828b --- /dev/null +++ b/configs/experiments/earnings_only_v1.json @@ -0,0 +1,16 @@ +{ + "experiment_name": "earnings_only_v1", + "dataset_snapshot_id": "a6687401-afdd-4bb7-9bb1-4cd32c5190bb", + "description": "Earnings-only baseline: tests PEAD alpha without dilution from other event types.", + "base_config": "configs/backtest/defaults.json", + "overrides": { + "event_type_profiles": { + "earnings_release": { + "enabled": true, + "direction_filter": "bullish_only" + } + }, + "signal": { "score_threshold": 0.45 } + }, + "tags": ["earnings-only", "baseline", "phase6"] +} diff --git a/libs/backtest/allocator.py b/libs/backtest/allocator.py index 9337dcf..1076a4c 100644 --- a/libs/backtest/allocator.py +++ b/libs/backtest/allocator.py @@ -95,7 +95,7 @@ def run_entry_gates( """Run entry gates. Returns skip_reason string or None (pass). Gates (in order): - 0. Macro regime (SPY below SMA — bearish market) + 0. Macro regime (SPY below SMA — hard block only if size_scaler >= 1.0) 1. Kill switch (drawdown >= threshold) 2. Max total positions 3. Duplicate symbol already open @@ -103,19 +103,21 @@ def run_entry_gates( 5. Daily new risk budget 6. Cash available (estimated position cost) 7. Loss-streak cooldown - 8. SUE gate (earnings: positive surprise required) + 8. (removed — SUE gate) 9. Event-type direction filter (bullish_only) 10. High one-off risk (veto: oneoff_penalty >= threshold) 11. Low parse confidence (veto: parse_confidence < threshold) 12. Unknown direction (veto: event_direction == "unknown") 13. Bearish direction (veto: event_direction == "bearish") """ - # Gate 0: Macro regime filter + # Gate 0: Macro regime filter (hard block only when size_scaler >= 1.0) if config.risk.macro_regime_enabled and macro_data: spy_close = macro_data.get("spy_close") spy_sma = macro_data.get("spy_sma_20") if spy_close is not None and spy_sma is not None and spy_close < spy_sma: - return "macro_regime_unfavorable" + if config.risk.macro_regime_size_scaler >= 1.0: + return "macro_regime_unfavorable" + # else: size scaler applied in build_planned_order # Gate 1: Kill switch if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT: @@ -156,12 +158,6 @@ def run_entry_gates( if cooldown_remaining > 0: return "cooldown" - # Gate 8: SUE gate — for earnings_release events, require positive surprise - if candidate.event_type == "earnings_release": - eps_growth = candidate.features.get("eps_growth_qoq") - if eps_growth is not None and float(eps_growth) <= 0: - return "negative_earnings_surprise" - # Gate 9: Event-type direction filter profile = config.get_event_profile(candidate.event_type) if profile and profile.direction_filter == "bullish_only": @@ -245,6 +241,21 @@ def build_planned_order( if shares == 0: skip_reason = "zero_shares" else: + # Apply macro size scaler when SPY < SMA and scaler < 1.0 + if ( + config.risk.macro_regime_enabled + and config.risk.macro_regime_size_scaler < 1.0 + and macro_data + ): + spy_close = macro_data.get("spy_close") + spy_sma = macro_data.get("spy_sma_20") + if ( + spy_close is not None + and spy_sma is not None + and spy_close < spy_sma + ): + shares = max(1, math.floor(shares * config.risk.macro_regime_size_scaler)) + risk_dollars = (candidate.entry_price_est - stop_price) * shares return PlannedOrder( diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 2e4a7e6..1a97067 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -25,6 +25,7 @@ class ExitReason(str, Enum): TRAILING = "TRAILING" KILL_SWITCH = "KILL_SWITCH" MISSING_BAR = "MISSING_BAR" + NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH" class BacktestMode(str, Enum): @@ -157,6 +158,7 @@ class MetricsBundle(BaseModel): avg_holding_days: float | None = None stop_exit_rate: float | None = None target_exit_rate: float | None = None + no_follow_through_exit_rate: float | None = None score_bucket_hit_rate: dict[str, float] = Field(default_factory=dict) # Bootstrap confidence intervals (95%) @@ -192,10 +194,12 @@ class RiskConfig(BaseModel): cooldown_after_loss_streak: int = 0 # consecutive losses to trigger cooldown cooldown_days: int = 0 # days to sit out after streak macro_regime_enabled: bool = False # block entries when SPY < SMA + macro_regime_size_scaler: float = 1.0 # size scaler when SPY < SMA (< 1.0 = scale down instead of block) macro_sma_period: int = 20 # SMA lookback for macro regime stop_atr_multiplier: float = 1.5 # ATR multiplier for stop distance backtest_mode: str = "research" # "research" or "live" kill_switch_cooldown_days: int = 20 # trading days before reset (research only) + kill_switch_log_only: bool = False # log-only mode (don't trigger, just observe) veto_oneoff_penalty: float = 0.5 # block if oneoff_penalty >= this veto_parse_confidence_min: float = 0.4 # block if parse_confidence < this veto_unknown_direction: bool = True # block if event_direction == "unknown" @@ -216,6 +220,7 @@ class ExecutionConfig(BaseModel): trailing_model: str | None = None trailing_warmup_days: int = 0 # days after entry before trailing activates max_holding_days: int = 10 + no_follow_through_exit: bool = False # exit at D+1 close if close < entry price class EventTypeProfile(BaseModel): diff --git a/libs/backtest/execution.py b/libs/backtest/execution.py index 7d49c4e..f36bfbd 100644 --- a/libs/backtest/execution.py +++ b/libs/backtest/execution.py @@ -158,6 +158,17 @@ def simulate_exit( else: exit_fill_price = position.entry_price # fallback (shouldn't happen) + # No-follow-through early exit (D+1 close < entry) + if ( + exit_reason is None + and config.no_follow_through_exit + and position.days_held == 1 + and bar_close is not None + and float(bar_close) < position.entry_price + ): + exit_reason = ExitReason.NO_FOLLOW_THROUGH + exit_fill_price = _long_exit_fill(float(bar_close), slippage) + if exit_reason is None or exit_fill_price is None: return None diff --git a/libs/backtest/metrics.py b/libs/backtest/metrics.py index 7c307e5..e012a99 100644 --- a/libs/backtest/metrics.py +++ b/libs/backtest/metrics.py @@ -281,6 +281,15 @@ def compute_target_exit_rate(trades: list[FilledTrade]) -> float | None: return targets / len(trades) +def compute_no_follow_through_rate(trades: list[FilledTrade]) -> float | None: + from libs.backtest.domain import ExitReason + + if not trades: + return None + nft = sum(1 for t in trades if t.exit_reason == ExitReason.NO_FOLLOW_THROUGH) + return nft / len(trades) + + def compute_score_bucket_hit_rate(trades: list[FilledTrade], candidate_map: dict[str, object]) -> dict[str, float]: """Win rate per score_bucket (uses trade_id -> candidate mapping).""" bucket_wins: dict[str, int] = defaultdict(int) @@ -409,6 +418,7 @@ def build_metrics_bundle( avg_holding_days=compute_avg_holding_days(trades), stop_exit_rate=compute_stop_exit_rate(trades), target_exit_rate=compute_target_exit_rate(trades), + no_follow_through_exit_rate=compute_no_follow_through_rate(trades), score_bucket_hit_rate=compute_score_bucket_hit_rate(trades, candidate_map or {}), # Bootstrap CIs bootstrap_cis=cis, diff --git a/libs/backtest/scoring.py b/libs/backtest/scoring.py index f4a5758..81cccea 100644 --- a/libs/backtest/scoring.py +++ b/libs/backtest/scoring.py @@ -1,24 +1,25 @@ """Rule-based entry score model for the backtester. -Computes a composite score in [0, 1] from event, market, and text features -available at entry time (no forward-looking data). Higher score = more -favorable entry conditions for a long swing trade. - -Event/Document Quality (55% weight — primary signal): - - Event quality (20%) — parser confidence + signal strength - - Earnings surprise (12%) — SUE/EPS growth (earnings events only) - - Risk penalty (10%) — oneoff risk flags reduce score - - Parse confidence (8%) — parse_confidence_overall from parser - - Direction clarity (5%) — event_direction categorical field - -Market Confirmation (35% weight — secondary signal): - - Reaction quality (12%) — moderate positive return is ideal - - Close strength (10%) — close near high = buyers won the day - - Volume conviction (8%) — above-average but not exhaustion - - Gap quality (5%) — small positive gap = orderly strength - -Text (10% weight — filing sentiment): - - LM sentiment (10%) — Loughran-McDonald filing tone +Computes a composite score in [0, 1] from alpha-only features available at +entry time (no forward-looking data). Higher score = more favorable entry +conditions for a long swing trade. + +Alpha features (5 components, 100% weight): + Event Quality (35%): + - Event quality (35%) — parser confidence + signal strength + guidance + + Market Confirmation (65%): + - Reaction quality (25%) — moderate positive return is ideal + - Close strength (18%) — close near high = buyers won the day + - Volume conviction (14%) — above-average but not exhaustion + - Gap quality (8%) — small positive gap = orderly strength + +Non-alpha features (removed from composite, retained for analysis scripts): + - Earnings surprise — eps_growth_qoq is sequential growth, not real surprise + - Risk penalty — already hard-gated at Gate 10 + - Parse confidence — already hard-gated at Gate 11 + - Direction clarity — already hard-gated at Gates 12-13 + - LM sentiment — 70-word dictionary is noise for SEC filings """ from __future__ import annotations @@ -30,49 +31,34 @@ logger = get_logger(__name__) def compute_entry_score(row: dict[str, Any]) -> float: - """Compute composite entry score from event + market + text features. + """Compute composite entry score from alpha-only features. Components and weights: - Event/Document Quality (55%): - 1. Event quality (20%) — parser confidence + signal strength - 2. Earnings surprise (12%) — SUE/EPS growth (earnings events only) - 3. Risk penalty (10%) — oneoff risk flags reduce score - 4. Parse confidence (8%) — parse_confidence_overall from parser - 5. Direction clarity (5%) — event_direction categorical field - Market Confirmation (35%): - 6. Reaction quality (12%) — moderate positive return is ideal - 7. Close strength (10%) — close near high = buyers won the day - 8. Volume conviction (8%) — above-average but not exhaustion - 9. Gap quality (5%) — small positive gap = orderly strength - Text (10%): - 10. LM sentiment (10%) — Loughran-McDonald filing tone + Event Quality (35%): + 1. Event quality (35%) — parser confidence + signal strength + Market Confirmation (65%): + 2. Reaction quality (25%) — moderate positive return is ideal + 3. Close strength (18%) — close near high = buyers won the day + 4. Volume conviction (14%) — above-average but not exhaustion + 5. Gap quality (8%) — small positive gap = orderly strength Returns float in [0.0, 1.0]. """ - # Event/Document Quality components + # Alpha features only event = _event_quality_score(row) - sue = _earnings_surprise_score(row) - risk = _risk_penalty_score(row) - parse_conf = _parse_confidence_score(row) - direction = _direction_clarity_score(row) - - # Market Confirmation components reaction = _reaction_score(row) close = _close_strength_score(row) volume = _volume_score(row) gap = _gap_score(row) - # Text sentiment component - text = _text_sentiment_score(row) - raw = ( - # Event/Document Quality (55%) - event * 0.20 + sue * 0.12 + risk * 0.10 - + parse_conf * 0.08 + direction * 0.05 - # Market Confirmation (35%) - + reaction * 0.12 + close * 0.10 + volume * 0.08 + gap * 0.05 - # Text (10%) - + text * 0.10 + # Event Quality (35%) + event * 0.35 + # Market Confirmation (65%) + + reaction * 0.25 + + close * 0.18 + + volume * 0.14 + + gap * 0.08 ) return max(0.0, min(1.0, raw)) diff --git a/libs/backtest/selector.py b/libs/backtest/selector.py index 3df8fae..acf9093 100644 --- a/libs/backtest/selector.py +++ b/libs/backtest/selector.py @@ -161,16 +161,23 @@ def filter_by_event_type( candidates: list[Candidate], profiles: dict[str, EventTypeProfile], ) -> list[Candidate]: - """Filter out candidates whose event_type is disabled or below per-type threshold.""" + """Filter out candidates whose event_type is unknown, disabled, or below per-type threshold. + + Default-deny: if profiles dict is non-empty and event_type is not in profiles, + the candidate is skipped (unknown event types are blocked). + """ if not profiles: return candidates filtered = [] for c in candidates: profile = profiles.get(c.event_type) - if profile is not None and not profile.enabled: + if profile is None: + logger.debug("skip_unknown_event_type", symbol=c.symbol, event_type=c.event_type) + continue + if not profile.enabled: logger.debug("skip_disabled_event_type", symbol=c.symbol, event_type=c.event_type) continue - if profile is not None and profile.score_threshold_override is not None: + if profile.score_threshold_override is not None: if c.score < profile.score_threshold_override: logger.debug( "skip_event_type_score", diff --git a/tests/unit/backtest/test_allocator.py b/tests/unit/backtest/test_allocator.py index 7d498dc..af92976 100644 --- a/tests/unit/backtest/test_allocator.py +++ b/tests/unit/backtest/test_allocator.py @@ -231,17 +231,50 @@ class TestRunEntryGates: class TestMacroRegimeGate: """Macro regime filter gate tests.""" - def test_blocks_when_spy_below_sma(self): + def test_blocks_when_spy_below_sma_and_scaler_gte_1(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True + cfg.risk.macro_regime_size_scaler = 1.0 # default — hard block macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # SPY below SMA result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result == "macro_regime_unfavorable" + def test_passes_when_spy_below_sma_and_scaler_lt_1(self): + from libs.backtest.allocator import run_entry_gates + + c = _make_candidate() + ps = _make_portfolio_state() + cfg = _make_config() + cfg.risk.macro_regime_enabled = True + cfg.risk.macro_regime_size_scaler = 0.5 # size scaler — don't hard block + macro = {"spy_close": 490.0, "spy_sma_20": 500.0} + result = run_entry_gates(c, ps, [], cfg, macro_data=macro) + assert result is None # passes gate, size scaler applied in build_planned_order + + def test_macro_size_scaler_reduces_shares(self): + from libs.backtest.allocator import build_planned_order + + c = _make_candidate(entry_price_est=100.0, atr_14=2.0) + ps = _make_portfolio_state() + cfg = _make_config() + cfg.risk.macro_regime_enabled = True + cfg.risk.macro_regime_size_scaler = 0.5 + macro = {"spy_close": 490.0, "spy_sma_20": 500.0} + + # Without macro scaler + order_normal = build_planned_order(c, ps, [], cfg, macro_data=None) + # With macro scaler + order_scaled = build_planned_order(c, ps, [], cfg, macro_data=macro) + + assert order_normal.skip_reason is None + assert order_scaled.skip_reason is None + assert order_scaled.shares < order_normal.shares + assert order_scaled.shares >= 1 + def test_passes_when_spy_above_sma(self): from libs.backtest.allocator import run_entry_gates @@ -296,13 +329,14 @@ class TestMacroRegimeGate: result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None # Can't evaluate, don't block - def test_build_planned_order_with_macro(self): + def test_build_planned_order_with_macro_hard_block(self): from libs.backtest.allocator import build_planned_order c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True + cfg.risk.macro_regime_size_scaler = 1.0 # hard block mode macro = {"spy_close": 490.0, "spy_sma_20": 500.0} order = build_planned_order(c, ps, [], cfg, macro_data=macro) assert order.skip_reason == "macro_regime_unfavorable" @@ -337,41 +371,6 @@ class TestComputeTargetPrice: assert target == pytest.approx(110.0) # falls back to fixed_r -class TestSUEEntryGate: - def test_negative_eps_growth_blocked(self): - from libs.backtest.allocator import run_entry_gates - - c = _make_candidate( - event_type="earnings_release", - features={"eps_growth_qoq": -0.05}, - ) - ps = _make_portfolio_state() - result = run_entry_gates(c, ps, [], _make_config()) - assert result == "negative_earnings_surprise" - - def test_positive_eps_growth_passes(self): - from libs.backtest.allocator import run_entry_gates - - c = _make_candidate( - event_type="earnings_release", - features={"eps_growth_qoq": 0.10}, - ) - ps = _make_portfolio_state() - result = run_entry_gates(c, ps, [], _make_config()) - assert result is None - - def test_non_earnings_not_checked(self): - from libs.backtest.allocator import run_entry_gates - - c = _make_candidate( - event_type="guidance_update", - features={"eps_growth_qoq": -0.50}, - ) - ps = _make_portfolio_state() - result = run_entry_gates(c, ps, [], _make_config()) - assert result is None - - class TestDirectionFilter: def test_bullish_only_blocks_bearish(self): from libs.backtest.allocator import run_entry_gates diff --git a/tests/unit/backtest/test_scoring.py b/tests/unit/backtest/test_scoring.py index f49bf16..85569b1 100644 --- a/tests/unit/backtest/test_scoring.py +++ b/tests/unit/backtest/test_scoring.py @@ -12,6 +12,7 @@ from libs.backtest.scoring import ( _parse_confidence_score, _reaction_score, _risk_penalty_score, + _text_sentiment_score, _volume_score, compute_entry_score, ) @@ -133,7 +134,10 @@ class TestComputeEntryScore: assert score == pytest.approx(0.5, abs=0.02) def test_ideal_setup_scores_high(self): - """Moderate positive return + close near high + healthy volume.""" + """Moderate positive return + close near high + healthy volume. + + event=0.5*0.35 + reaction=0.9*0.25 + close=0.82*0.18 + volume=0.8*0.14 + gap=0.8*0.08 ≈ 0.72 + """ row = { "reaction_day_return": 0.01, # sweet spot → 0.9 "close_location": 0.8, # near high → 0.82 @@ -141,7 +145,7 @@ class TestComputeEntryScore: "gap_size": 0.01, # orderly → 0.8 } score = compute_entry_score(row) - assert score > 0.60 + assert score > 0.65 def test_bearish_setup_scores_low(self): """Negative return + close near low + below avg volume.""" @@ -152,7 +156,7 @@ class TestComputeEntryScore: "gap_size": -0.03, # bearish gap → 0.2 } score = compute_entry_score(row) - assert score < 0.45 + assert score < 0.38 def test_extreme_positive_penalized(self): """Very large positive reaction should be penalized.""" @@ -215,8 +219,8 @@ class TestComputeEntryScore: "volume_ratio_20d": 1.45, "gap_size": 0.006, }) - assert aapl > 0.55, f"AAPL should be above 0.55, got {aapl:.3f}" - assert tsla < 0.48, f"TSLA should be below 0.48, got {tsla:.3f}" + assert aapl > 0.65, f"AAPL should be above 0.65, got {aapl:.3f}" + assert tsla < 0.46, f"TSLA should be below 0.46, got {tsla:.3f}" assert aapl > tsla def test_event_features_boost_score(self): @@ -239,19 +243,32 @@ class TestComputeEntryScore: }) assert with_events > market_only - def test_high_risk_penalty_lowers_score(self): - """High oneoff_penalty should lower overall score.""" - low_risk = compute_entry_score({ - "reaction_day_return": 0.01, - "close_location": 0.6, - "oneoff_penalty": 0.0, - }) - high_risk = compute_entry_score({ + def test_removed_features_dont_affect_composite(self): + """Changing non-alpha features should not change composite score. + + eps_growth_qoq, oneoff_penalty, parse_confidence_overall, + event_direction, and lm_net_sentiment are excluded from composite. + """ + base_row = { "reaction_day_return": 0.01, "close_location": 0.6, - "oneoff_penalty": 1.0, - }) - assert low_risk > high_risk + "volume_ratio_20d": 1.5, + "gap_size": 0.01, + } + base_score = compute_entry_score(base_row) + + # Varying removed features should not change score + for extra in [ + {"eps_growth_qoq": 0.50}, + {"oneoff_penalty": 1.0}, + {"parse_confidence_overall": 0.9}, + {"event_direction": "bullish"}, + {"lm_net_sentiment": 0.01}, + ]: + row = {**base_row, **extra} + assert compute_entry_score(row) == pytest.approx(base_score), ( + f"Feature {list(extra.keys())[0]} should not affect composite" + ) class TestEarningsSurpriseScore: diff --git a/tests/unit/backtest/test_selector.py b/tests/unit/backtest/test_selector.py index c856186..667612c 100644 --- a/tests/unit/backtest/test_selector.py +++ b/tests/unit/backtest/test_selector.py @@ -206,6 +206,7 @@ class TestFilterByEventType: ] candidates = [build_candidate(r) for r in rows if build_candidate(r)] profiles = { + "earnings_release": EventTypeProfile(enabled=True), "management_change": EventTypeProfile(enabled=False), } filtered = filter_by_event_type(candidates, profiles) @@ -227,6 +228,22 @@ class TestFilterByEventType: assert len(filtered) == 1 assert filtered[0].symbol == "B" + def test_unknown_event_type_blocked(self): + """Event types not in profiles dict are blocked (default deny).""" + from libs.backtest.selector import build_candidate, filter_by_event_type + + rows = [ + _make_raw_row(symbol="A", event_type="earnings_release"), + _make_raw_row(symbol="B", event_type="unknown_type"), + ] + candidates = [build_candidate(r) for r in rows if build_candidate(r)] + profiles = { + "earnings_release": EventTypeProfile(enabled=True), + } + filtered = filter_by_event_type(candidates, profiles) + assert len(filtered) == 1 + assert filtered[0].symbol == "A" + def test_no_profiles_passthrough(self): from libs.backtest.selector import build_candidate, filter_by_event_type @@ -263,7 +280,10 @@ class TestSelectCandidates: ] u = UniverseConfig(min_price=5.0, min_avg_dollar_volume=1_000_000) s = SignalConfig(score_threshold=0.5, max_candidates_per_day=10) - profiles = {"management_change": EventTypeProfile(enabled=False)} + profiles = { + "earnings_release": EventTypeProfile(enabled=True), + "management_change": EventTypeProfile(enabled=False), + } result = select_candidates(rows, u, s, event_type_profiles=profiles) assert len(result) == 1 assert result[0].symbol == "A"