You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

317 lines
10 KiB
Python

"""PEAD single-ticker advisor.
`evaluate_pead_buy` mirrors the backtester's per-engine select_candidates loop
on a single (ticker, asof). `evaluate_pead_sell` reconstructs the entry-day
candidate to lock the engine-specific stop/target and runs the same
`simulate_exit` the backtester uses against today's daily bar.
"""
from __future__ import annotations
import datetime as dt
import os
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field
from libs.backtest.allocator import (
_resolve_stop_risk_config,
compute_stop_price,
compute_target_price,
)
from libs.backtest.domain import (
BacktestConfig,
Candidate,
OpenPosition,
PlannedOrder,
PositionStatus,
)
from libs.backtest.execution import build_effective_execution_config, simulate_exit
from libs.backtest.manifests import load_manifest, resolve_config
from libs.backtest.selector import select_candidates
from libs.common.logging import get_logger
logger = get_logger(__name__)
Signal = Literal["BUY", "SELL", "HOLD", "NO_SIGNAL", "ERROR"]
class PeadVerdict(BaseModel):
signal: Signal
score: float | None = None
reason: str
details: dict[str, Any] = Field(default_factory=dict)
warning: str | None = None
def _oracle_url() -> str:
return os.environ.get("ORACLE_URL") or os.environ.get(
"STOCK_ORACLE_URL", "http://localhost:8000"
)
def _db_dsn() -> str:
return os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "")
def _load_config(config_path: str | Path) -> BacktestConfig:
manifest = load_manifest(config_path)
return resolve_config(manifest)
async def _detector_rows_for_ticker(
ticker: str,
asof: dt.date,
config: BacktestConfig,
) -> list[dict[str, Any]]:
from apps.paper_trader.event_detector import EventDetector
detector = EventDetector(db_dsn=_db_dsn(), oracle_url=_oracle_url())
rows = await detector.get_candidates_for_date(asof, config, convention="reaction_close")
upper = ticker.upper()
return [r for r in rows if str(r.get("symbol", "")).upper() == upper]
def _try_each_engine(
rows: list[dict[str, Any]],
config: BacktestConfig,
) -> tuple[Candidate | None, str | None, list[str]]:
"""Loop enabled engines and return the first matching candidate.
Returns (candidate, engine_id, engines_tried). When no engine matches,
candidate and engine_id are None.
"""
engines = config.get_active_strategy_engines()
tried: list[str] = []
for engine_cfg in engines:
tried.append(engine_cfg.engine_id)
candidates = select_candidates(
raw_rows=rows,
universe_config=config.universe,
signal_config=config.signal,
event_type_profiles=config.event_type_profiles or {},
strategy_engine=engine_cfg,
)
if candidates:
return candidates[0], engine_cfg.engine_id, tried
return None, None, tried
async def evaluate_pead_buy(
ticker: str,
asof: dt.date,
config_path: str | Path,
) -> PeadVerdict:
config = _load_config(config_path)
rows = await _detector_rows_for_ticker(ticker, asof, config)
if not rows:
return PeadVerdict(
signal="NO_SIGNAL",
reason=(
f"no qualifying filing event in pipeline DB for {ticker.upper()} on "
f"{asof.isoformat()}; PEAD only acts on parsed filing events"
),
)
candidate, engine_id, tried = _try_each_engine(rows, config)
if candidate is None:
return PeadVerdict(
signal="NO_SIGNAL",
reason=(
f"event present for {ticker.upper()} on {asof.isoformat()} but no engine "
f"accepted it (universe / score / event-type / direction gate failed for all "
f"{len(tried)} engines)"
),
details={"engines_tried": tried, "row_count": len(rows)},
)
return PeadVerdict(
signal="BUY",
score=candidate.score,
reason=f"engine {engine_id} accepted; score={candidate.score:.3f}",
details={
"engine_id": engine_id,
"score": candidate.score,
"event_type": candidate.event_type,
"trade_direction": candidate.trade_direction,
"entry_price_est": candidate.entry_price_est,
"atr_14": candidate.atr_14,
"sector": candidate.sector,
"score_bucket": candidate.score_bucket,
},
)
async def evaluate_pead_sell(
ticker: str,
entry_date: dt.date,
entry_price: float,
asof: dt.date,
config_path: str | Path,
) -> PeadVerdict:
config = _load_config(config_path)
entry_rows = await _detector_rows_for_ticker(ticker, entry_date, config)
if not entry_rows:
return PeadVerdict(
signal="ERROR",
reason=(
f"no pipeline-DB event for {ticker.upper()} on entry_date="
f"{entry_date.isoformat()}; cannot reconstruct stop/target — was this "
"position taken under PEAD?"
),
)
candidate, engine_id, tried = _try_each_engine(entry_rows, config)
if candidate is None or engine_id is None:
return PeadVerdict(
signal="ERROR",
reason=(
f"event present on entry_date={entry_date.isoformat()} but no PEAD engine "
f"would have accepted it (tried {len(tried)} engines); cannot reconstruct "
"engine-specific stop/target deterministically"
),
details={"engines_tried": tried},
)
exec_cfg = build_effective_execution_config(candidate, config)
# Engine-locked stop distance based on entry-date ATR; translate to user's actual entry price.
risk_cfg_for_stop = _resolve_stop_risk_config(candidate, config)
synthetic_stop = compute_stop_price(candidate, risk_cfg_for_stop)
risk_per_share = abs(candidate.entry_price_est - synthetic_stop)
if risk_per_share <= 0:
return PeadVerdict(
signal="ERROR",
reason="reconstructed stop distance is zero — atr_14 missing or invalid on entry_date",
details={"engine_id": engine_id},
)
if candidate.trade_direction == "short":
actual_stop = entry_price + risk_per_share
else:
actual_stop = max(0.01, entry_price - risk_per_share)
target_r = (
candidate.engine_target_1_r
if candidate.engine_target_1_r is not None
else (exec_cfg.target_1_r if exec_cfg.target_1_r is not None else 2.0)
)
actual_target = compute_target_price(
entry_price_est=entry_price,
stop_price=actual_stop,
target_r=target_r,
target_model=exec_cfg.target_model,
target_atr_multiplier=exec_cfg.target_atr_multiplier,
atr_14=candidate.atr_14,
trade_direction=candidate.trade_direction,
)
plan = PlannedOrder(
candidate=candidate,
shares=1,
entry_price_limit=entry_price,
stop_price=actual_stop,
target_price=actual_target,
risk_dollars=risk_per_share,
engine_id=engine_id,
entry_timing_policy=candidate.entry_timing_policy,
)
days_held = _trading_day_count(entry_date, asof)
position = OpenPosition(
position_id=f"advisor_{ticker.upper()}_{entry_date.isoformat()}",
plan=plan,
entry_date=entry_date,
entry_price=entry_price,
entry_fill_slippage_bps=0.0,
current_stop=actual_stop,
target_price=actual_target,
peak_price=entry_price,
shares_open=1,
shares_total=1,
days_held=days_held,
status=PositionStatus.ENTERED,
)
bar = await _fetch_daily_bar(ticker, asof)
base_details: dict[str, Any] = {
"engine_id": engine_id,
"stop_price": actual_stop,
"target_price": actual_target,
"days_held": days_held,
"entry_date_atr_14": candidate.atr_14,
"risk_per_share": risk_per_share,
}
warning = (
"stop/target reconstructed from entry-date ATR; trailing-stop and partial-exit "
"state not modeled, so an actual backtest could exit earlier"
)
if bar is None:
return PeadVerdict(
signal="HOLD",
reason=(
f"no daily bar available for {ticker.upper()} on {asof.isoformat()} "
"(market may not have closed yet)"
),
details=base_details,
warning=warning,
)
base_details.update({
"today_high": bar.get("high"),
"today_low": bar.get("low"),
"today_close": bar.get("close"),
})
filled = simulate_exit(position, bar, exec_cfg, asof)
if filled is None:
return PeadVerdict(
signal="HOLD",
reason=f"no exit triggered today; days_held={days_held}",
details=base_details,
warning=warning,
)
return PeadVerdict(
signal="SELL",
reason=f"exit triggered: {filled.exit_reason.value}",
details={
**base_details,
"exit_reason": filled.exit_reason.value,
"exit_price": filled.exit_price,
"pnl_pct": filled.pnl_pct,
"r_multiple": filled.r_multiple,
},
warning=warning,
)
async def _fetch_daily_bar(ticker: str, asof: dt.date) -> dict[str, Any] | None:
from libs.oracle_client.client import OracleClient
from libs.oracle_client.price import PriceService
async with OracleClient(base_url=_oracle_url()) as oc:
svc = PriceService(oc)
try:
resp = await svc.get_daily_bars(
ticker.upper(), start=asof.isoformat(), end=asof.isoformat()
)
except Exception as exc:
logger.warning("advisor_pead_bar_fetch_failed", ticker=ticker, asof=asof.isoformat(), error=str(exc))
return None
for b in resp.bars:
bar_date = b.date if isinstance(b.date, dt.date) else dt.date.fromisoformat(str(b.date)[:10])
if bar_date == asof:
return {"open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
if resp.bars:
b = resp.bars[-1]
return {"open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume}
return None
def _trading_day_count(entry_date: dt.date, asof: dt.date) -> int:
if asof <= entry_date:
return 0
try:
from libs.backtest.calendar import get_trading_days
days = get_trading_days(entry_date, asof)
return max(0, len(days) - 1)
except Exception:
return (asof - entry_date).days