diff --git a/app_lib.py b/app_lib.py index 55702bc..de3c3cd 100644 --- a/app_lib.py +++ b/app_lib.py @@ -23,7 +23,7 @@ from osl.backtest.rules import IronCondor1Sigma, LongStraddle, ShortPut16Delta from osl.config import Settings, get_settings from osl.data.providers import Freshness, build_provider, freshness_badge -from osl.report.playbook import PlaybookData, StrategyRow +from osl.report.playbook import LegRow, PlaybookData, StrategyRow from osl.strategy.enumerate import View, enumerate_candidates from osl.strategy.liquidity import strategy_liquidity from osl.strategy.metrics import compute_metrics @@ -639,6 +639,18 @@ def build_playbook_data( break best = rank(candidates, obj)[0] m = best.metrics + legs = tuple( + LegRow( + side="Sell" if leg.sign < 0 else "Buy", + quantity=int(leg.quantity), + right=leg.right, + strike=float(leg.strike), + expiry=leg.expiration.isoformat(), + premium=float(leg.premium), + iv=float(leg.iv), + ) + for leg in best.strategy.legs + ) rows.append( StrategyRow( name=best.strategy.name, @@ -651,6 +663,8 @@ def build_playbook_data( loss_unbounded=m.loss_unbounded, liquidity=best.liquidity.score, breakevens=m.breakevens, + net_debit=float(best.strategy.net_debit), + legs=legs, ) ) diff --git a/osl/report/playbook.py b/osl/report/playbook.py index b81223f..510e874 100644 --- a/osl/report/playbook.py +++ b/osl/report/playbook.py @@ -18,6 +18,10 @@ _COLUMN_GLOSSARY: tuple[tuple[str, str], ...] = ( ("Objective", "The ranking goal this strategy topped (e.g. EV per risk, theta per risk)."), ("Strategy", "The option structure (vertical, iron condor, strangle, ...)."), + ( + "Net", + "Net cash at entry, in dollars: debit (paid) is positive, credit (received) is negative.", + ), ("Expiry", "Expiration date of the nearest leg."), ( "POP (RN)", @@ -30,6 +34,29 @@ ("Liquidity", "0-1 tradability score from spread, open interest, volume and ATM distance."), ) +_TICKET_HEADERS: tuple[str, ...] = ( + "Action", + "Qty", + "Right", + "Strike", + "Expiry", + "Premium (mid)", + "IV", +) + + +@dataclass(frozen=True) +class LegRow: + """One leg of a strategy, in the form needed to actually place the trade.""" + + side: str # "Buy" or "Sell" + quantity: int + right: str # "C" or "P" + strike: float + expiry: str # ISO date + premium: float # per-share mid + iv: float + @dataclass(frozen=True) class StrategyRow: @@ -43,6 +70,10 @@ class StrategyRow: loss_unbounded: bool liquidity: float breakevens: tuple[float, ...] + # Execution detail. Defaults preserve back-compat with older callers/tests + # that did not carry leg-level data. + net_debit: float = 0.0 + legs: tuple[LegRow, ...] = () @dataclass(frozen=True) @@ -90,9 +121,18 @@ def _fmt_money(x: float) -> str: return "∞ (uncovered)" if x == float("-inf") else f"{x:,.2f}" +def _fmt_net(x: float) -> str: + """Format net entry cash: positive = debit, negative = credit.""" + if x > 0: + return f"${x:,.2f} debit" + if x < 0: + return f"${abs(x):,.2f} credit" + return "$0.00" + + def _strategy_rows_html(rows: Sequence[StrategyRow]) -> str: if not rows: - return "No candidates." + return "No candidates." out = [] for r in rows: max_loss = "∞ (uncovered)" if r.loss_unbounded else _fmt_money(r.max_loss) @@ -101,6 +141,7 @@ def _strategy_rows_html(rows: Sequence[StrategyRow]) -> str: "" f"{escape(r.objective)}" f"{escape(r.name)}" + f"{escape(_fmt_net(r.net_debit))}" f"{escape(r.expiry)}" f"{r.pop_rn:.1%}" f"{r.ev:,.0f}" @@ -113,6 +154,39 @@ def _strategy_rows_html(rows: Sequence[StrategyRow]) -> str: return "\n".join(out) +def _trade_tickets_html(rows: Sequence[StrategyRow]) -> str: + """Per-strategy execution blocks: the exact legs (side, qty, right, strike, expiry).""" + if not rows or not any(r.legs for r in rows): + return "" + headers = "".join(f"{escape(h)}" for h in _TICKET_HEADERS) + blocks = [] + for r in rows: + if not r.legs: + continue + leg_rows = "".join( + "" + f"{escape(leg.side)}" + f"{leg.quantity}" + f"{escape(leg.right)}" + f"{leg.strike:,.2f}" + f"{escape(leg.expiry)}" + f"{leg.premium:,.2f}" + f"{leg.iv:.1%}" + "" + for leg in r.legs + ) + bes = ", ".join(f"{b:,.2f}" for b in r.breakevens) or "—" + blocks.append( + '
' + f"

{escape(r.objective)}: {escape(r.name)} — {escape(r.expiry)}

" + f"{headers}{leg_rows}
" + f"

Net: {escape(_fmt_net(r.net_debit))}  ·  " + f"Breakevens: {escape(bes)}

" + "
" + ) + return "\n".join(blocks) + + def build_playbook_html(data: PlaybookData) -> str: """Render the playbook as a self-contained HTML document.""" digest = report_digest(data) @@ -130,19 +204,29 @@ def build_playbook_html(data: PlaybookData) -> str: Options Playbook — {escape(data.symbol)} @@ -165,6 +249,10 @@ def build_playbook_html(data: PlaybookData) -> str: +

Trade tickets

+

Exact legs for each top strategy — side, quantity, right (C/P), strike and expiry — so the trade can be entered directly.

+{_trade_tickets_html(data.strategies)} +

How to read this table

{glossary}