Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.11"
cache: pip
cache-dependency-path: requirements-ci.txt

Expand Down
21 changes: 5 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.11"
cache: pip
cache-dependency-path: requirements-release.txt

Expand Down Expand Up @@ -98,20 +98,9 @@ jobs:
- name: Resolve tag name
id: tag
shell: bash
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ github.event.inputs.version }}"
else
TAG="${GITHUB_REF_NAME}"
fi
# Normalize: strip leading whitespace
TAG="${TAG# }"
if [[ -z "$TAG" ]]; then
echo "Empty tag" >&2
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Using tag: $TAG"
env:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
run: bash scripts/resolve_release_tag.sh

- name: Download all build artifacts
uses: actions/download-artifact@v4
Expand Down
4 changes: 4 additions & 0 deletions build_macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ PYI_ARGS=(
--exclude-module matplotlib.backends.backend_gtk4cairo
--exclude-module matplotlib.backends.backend_nbagg
--exclude-module matplotlib.backends.backend_cairo
--collect-all pandas_market_calendars
--collect-all exchange_calendars
--collect-all tzdata
--add-data "$ROOT/data:data"
--collect-submodules matplotlib
)

Expand Down
37 changes: 37 additions & 0 deletions core/expiry_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""US equity-option remaining session time (NYSE calendar proxy).

Volatility and rates use a common 252 x 6.5-hour trading-year clock, preserving
Sentinel's trading-day convention. This is not an ACT/365 rate conversion. Early
closes contribute only their actual session duration. AM-settled/index products
need a product-specific calendar/settlement adapter and are not supported here.
"""
from datetime import datetime, timezone
from functools import lru_cache

import pandas as pd


@lru_cache(maxsize=64)
def _schedule(start, end, calendar_name):
import pandas_market_calendars as calendars
return calendars.get_calendar(calendar_name).schedule(start_date=start, end_date=end)


def remaining_years(expiry, valuation_time=None, *, calendar_name="NYSE"):
"""Remaining open-session seconds through expiry close; zero after expiry.

Explicit valuation times must be timezone-aware. A non-session expiry uses
the preceding session's close. All comparisons use UTC, independent of host TZ.
"""
now = pd.Timestamp(valuation_time if valuation_time is not None else datetime.now(timezone.utc))
if now.tzinfo is None:
raise ValueError("valuation_time must be timezone-aware")
now = now.tz_convert("UTC")
start = now.tz_convert("America/New_York").date()
end = pd.Timestamp(expiry).date()
if end < start:
return 0.0
schedule = _schedule(start.isoformat(), end.isoformat(), calendar_name)
seconds = sum(max(0.0, (row.market_close - max(now, row.market_open)).total_seconds())
for row in schedule.itertuples())
return seconds / (252.0 * 6.5 * 3600.0)
31 changes: 18 additions & 13 deletions core/graph_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,27 @@ def categorize_peers(graph: StockGraph, ticker: str) -> Dict[str, Any]:
node = graph.get_node(sym)
if not node:
raise ValueError(f"Ticker {sym!r} not found in stock graph.")
neighbors = graph.get_neighbors(sym, depth=1)
categorized: Dict[str, List[dict]] = {}
for n, edge in neighbors:
categorized.setdefault(edge.relation, []).append(
{
"ticker": n.ticker,
"name": n.name,
"sector": n.sector,
"sub_industry": n.sub_industry,
"description": edge.description,
"weight": edge.weight,
}
)
for edge in graph.edges:
if sym not in (edge.source, edge.target):
continue
peer = edge.target if edge.source == sym else edge.source
n = graph.get_node(peer)
if n is None:
continue
categorized.setdefault(edge.relation, []).append({
"ticker": n.ticker, "name": n.name, "sector": n.sector,
"sub_industry": n.sub_industry, "description": edge.description,
"weight": edge.weight, "source": edge.source, "target": edge.target,
"direction": "bidirectional" if edge.bidirectional else "outgoing" if edge.source == sym else "incoming",
})
return {
"action": "peers",
"ticker": sym,
"node": node.to_dict(),
"peer_categories": categorized,
"peer_count": sum(len(v) for v in categorized.values()),
"peer_count": len({p["ticker"] for peers in categorized.values() for p in peers}),
"relationship_count": sum(len(v) for v in categorized.values()),
}


Expand All @@ -62,6 +64,7 @@ def show_connections(
"action": "summary",
"total_nodes": len(nodes),
"total_edges": len(graph.edges),
"import_status": getattr(graph, "import_status", {"status": "not requested"}),
"sectors": sorted({n["sector"] for n in nodes}),
"tickers": sorted(n["ticker"] for n in nodes),
}
Expand All @@ -77,6 +80,8 @@ def show_connections(
"name": n.name,
"sector": n.sector,
"relation": edge.relation,
"source": edge.source,
"target": edge.target,
"description": edge.description,
"weight": edge.weight,
}
Expand Down
74 changes: 44 additions & 30 deletions core/scan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import pandas as pd

from core.pricing import VegaChimpCore
from core.expiry_time import remaining_years
from core.technicals import calculate_technicals
from core.vol_models import (
blend_forecast_vol,
Expand Down Expand Up @@ -145,7 +146,7 @@ def _as_decimal_yield(value: Any, *, percent_units: bool) -> Optional[float]:
return d


def resolve_dividend_yield(data_provider, stock_obj) -> float:
def resolve_dividend_yield(data_provider, stock_obj, *, status=None) -> float:
"""Dividend yield as a decimal fraction.

Per-source units (verified against live yfinance quotes, 2026-09):
Expand All @@ -155,27 +156,29 @@ def resolve_dividend_yield(data_provider, stock_obj) -> float:
old ``> 1`` heuristic misread as 98% / 34%.
Priority: fast_info → info.dividendYield → trailing; else 0.0.
"""
status = {} if status is None else status
status.update(source="missing", errors=[])
try:
fast_info = data_provider.get_fast_info(stock_obj)
fast_div = _as_decimal_yield(
fast_info.get("dividend_yield")
if isinstance(fast_info, dict)
else getattr(fast_info, "get", lambda *a: None)("dividend_yield"),
percent_units=False,
)
if fast_div is not None:
return fast_div
value = _as_decimal_yield(fast_info.get("dividend_yield"), percent_units=False)
if value is not None:
status["source"] = "fast_info.dividend_yield"
return value
except Exception as exc:
status["errors"].append({"source": "fast_info", "error": str(exc)})
try:
info = data_provider.get_info(stock_obj)
div = _as_decimal_yield(info.get("dividendYield"), percent_units=True)
if div is not None:
return div
div = _as_decimal_yield(
info.get("trailingAnnualDividendYield"), percent_units=False
)
if div is not None:
return div
except Exception:
pass
except Exception as exc:
status["errors"].append({"source": "info", "error": str(exc)})
info = {}
for field, percent in (("dividendYield", True), ("trailingAnnualDividendYield", False)):
try:
value = _as_decimal_yield(info.get(field), percent_units=percent)
if value is not None:
status["source"] = f"info.{field}"
return value
except Exception as exc:
status["errors"].append({"source": field, "error": str(exc)})
return 0.0


Expand Down Expand Up @@ -264,6 +267,10 @@ class OptionScanRow:
verdict: str
is_earnings: bool = False
tag: str = ""
bid: float = 0.0
ask: float = 0.0
category: Optional[str] = None
probability_model: str = "risk-neutral lognormal, market IV, long entry at ask"

def tree_vals(self) -> tuple:
"""Tuple matching Options Explorer column order."""
Expand All @@ -288,7 +295,9 @@ def tree_vals(self) -> tuple:
)

def to_dict(self) -> dict:
return asdict(self)
data = asdict(self)
data["category"] = self.category or self.verdict.rstrip(" !")
return data


@dataclass
Expand All @@ -298,6 +307,7 @@ class ScanResult:
forecast_vol: float = 0.0
spot: float = 0.0
dividend_yield: float = 0.0
dividend_status: dict = field(default_factory=dict)
rules_log: str = SCAN_RULES_LOG
errors: List[dict] = field(default_factory=list)
requested_expiries: int = 0
Expand Down Expand Up @@ -443,6 +453,7 @@ def scan_option_chains(
long_rate: Optional[float] = None,
log: Optional[LogFn] = None,
on_ui_batch: Optional[UiBatchFn] = None,
valuation_time: Optional[datetime] = None,
) -> ScanResult:
"""Run the Options Finder scan over ``dates``.

Expand All @@ -455,8 +466,9 @@ def _log(msg: str) -> None:
log(msg)

spot = float(spot)
dividend_status = {"source": "override", "errors": []}
if dividend_yield is None:
dividend_yield = resolve_dividend_yield(data_provider, stock)
dividend_yield = resolve_dividend_yield(data_provider, stock, status=dividend_status)
DIV_YIELD = float(dividend_yield)

if short_rate is None or long_rate is None:
Expand All @@ -472,7 +484,7 @@ def _log(msg: str) -> None:
)

opt_type = (option_type or "all").strip().lower()
today = datetime.now().date()
valuation_time = valuation_time or pd.Timestamp.now(tz="UTC")
ui_batch: List[Tuple[tuple, str]] = []
under_rows: List[Tuple[float, OptionScanRow]] = []
rows_out: List[OptionScanRow] = []
Expand All @@ -495,13 +507,10 @@ def flush_ui() -> None:
_log(SCAN_RULES_LOG)
rules_logged = True

exp_date = datetime.strptime(date, "%Y-%m-%d").date()
trading_days = int(np.busday_count(today, exp_date))
if trading_days < 0:
T = remaining_years(date, valuation_time)
if T <= 0:
_log(f"Skipping expired contract {date}")
continue
# 0DTE (expires today): half a session remains on average.
T = max(trading_days / 252.0, 0.5 / 252.0)
RFR = interpolate_rfr(_short_rate, _long_rate, T)

chain = data_provider.get_option_chain(stock, date)
Expand Down Expand Up @@ -644,9 +653,9 @@ def flush_ui() -> None:

try:
if kind_str == "call":
breakeven_price = strike + market_price
breakeven_price = strike + a
else:
breakeven_price = strike - market_price
breakeven_price = strike - a
if breakeven_price <= 0:
pop = 0.0
elif kind_str == "call":
Expand Down Expand Up @@ -680,9 +689,10 @@ def flush_ui() -> None:
"vol": float(vol_v[j]),
"is_earnings": is_earnings,
"is_good": is_undervalued,
"verdict": verdict,
})

breakeven = strike + market_price if kind_str == "call" else strike - market_price
breakeven = strike + a if kind_str == "call" else strike - a
tag = ""
if is_undervalued:
tag = "green"
Expand All @@ -699,6 +709,8 @@ def flush_ui() -> None:
volume=float(vol_v[j]),
oi=oi,
mid=market_price,
bid=b,
ask=a,
spread_pct=sp,
breakeven=breakeven,
iv=iv,
Expand All @@ -711,6 +723,7 @@ def flush_ui() -> None:
vega=float(greeks["vega"]),
pop=float(pop),
verdict=display_verdict,
category=verdict,
is_earnings=is_earnings,
tag=tag,
)
Expand Down Expand Up @@ -746,6 +759,7 @@ def flush_ui() -> None:
forecast_vol=forecast_vol,
spot=spot,
dividend_yield=DIV_YIELD,
dividend_status=dividend_status,
rules_log=SCAN_RULES_LOG,
errors=errors,
requested_expiries=len(dates),
Expand Down
9 changes: 5 additions & 4 deletions core/stock_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ def get_neighbors(
relation_types: Optional[Sequence[str]] = None,
) -> List[Tuple[StockNode, GraphEdge]]:
"""Find neighboring nodes up to a certain depth."""
if not isinstance(depth, int) or depth < 1:
raise ValueError("depth must be a positive integer")
sym = ticker.upper()
if sym not in self._nodes:
return []
Expand Down Expand Up @@ -373,9 +375,8 @@ def build_default_graph(*, include_sectivia: bool = True) -> StockGraph:
try:
from core.sectivia_import import merge_cached_sectivia

merge_cached_sectivia(g, optional=True)
except Exception:
# Offline / missing optional deps must not break default graph.
pass
g.import_status = merge_cached_sectivia(g, optional=True)
except Exception as exc:
g.import_status = {"status": "error", "error": str(exc)}
return g

Loading
Loading