fix: weekend-aware cycle — IVGate, pandas MultiIndex, ticker aliases, day/session in AI prompt
- auto_cycle.py: detect weekend/market session, build cycle_meta with day_of_week/is_weekend/market_note; IVGate skips iv_rank>=99 on weekends to avoid artificial weekend option premium cascade; inject portfolio context (open trades + price moves + concentration) before AI scoring; pass portfolio_context_block + run_id to both AI scorer and suggester - ai_analyzer.py: _build_temporal_news_block injects market session banner (WEEKEND warning, pre/after-market note, or open session label) so AI knows markets are closed and defers execution to Monday - iv_engine.py: add WHEAT/EUR/USD ticker aliases; skip saving IV snapshots on weekends to protect history; resolve aliases before slash-format conversion in _resolve_ticker - technical_indicators.py: fix pandas MultiIndex from yfinance>=0.2 (droplevel+squeeze); use period proportional to lookback instead of fixed period=1d - database.py: asset_class ticker-based fallback (_asset_class_from_ticker); one-time backfill migration for all NULL asset_class rows; ai_call_logs table + save/get helpers; normalize_ticker public function Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -970,9 +970,13 @@ def partition_news_by_age(news: List[Dict], delta_minutes: float) -> Dict[str, L
|
|||||||
|
|
||||||
|
|
||||||
def _build_temporal_news_block(partitioned: Dict[str, List], cycle_meta: Dict) -> str:
|
def _build_temporal_news_block(partitioned: Dict[str, List], cycle_meta: Dict) -> str:
|
||||||
"""Build the news section of the IA prompt with temporal framing."""
|
"""Build the news section of the IA prompt with temporal framing + market session context."""
|
||||||
delta_min = cycle_meta.get("delta_minutes", 180)
|
delta_min = cycle_meta.get("delta_minutes", 180)
|
||||||
calib = cycle_meta.get("calibration_label", "")
|
calib = cycle_meta.get("calibration_label", "")
|
||||||
|
day_of_week = cycle_meta.get("day_of_week", "")
|
||||||
|
is_weekend = cycle_meta.get("is_weekend", False)
|
||||||
|
market_session = cycle_meta.get("market_session", "")
|
||||||
|
market_note = cycle_meta.get("market_note", "")
|
||||||
|
|
||||||
def _fmt_news(articles: List[Dict], limit: int = 6) -> str:
|
def _fmt_news(articles: List[Dict], limit: int = 6) -> str:
|
||||||
lines = []
|
lines = []
|
||||||
@@ -989,7 +993,27 @@ def _build_temporal_news_block(partitioned: Dict[str, List], cycle_meta: Dict) -
|
|||||||
recent = partitioned.get("recent_24h", [])
|
recent = partitioned.get("recent_24h", [])
|
||||||
older = partitioned.get("older", [])
|
older = partitioned.get("older", [])
|
||||||
|
|
||||||
|
# Market session banner
|
||||||
|
if is_weekend:
|
||||||
|
session_banner = (
|
||||||
|
f"\n## 📅 STATUT DES MARCHÉS — {day_of_week.upper()}\n"
|
||||||
|
f"⚠️ WEEKEND — Marchés FERMÉS. {market_note}\n"
|
||||||
|
"→ Les patterns peuvent être identifiés mais NE PEUVENT PAS être exécutés avant lundi matin.\n"
|
||||||
|
"→ Utilise ce cycle pour préparer des ordres à cours limité pour l'ouverture de lundi.\n"
|
||||||
|
"→ Les prix affichés sont ceux de la CLÔTURE DE VENDREDI — ne pas interpréter les mouvements intraday.\n"
|
||||||
|
"→ La volatilité implicite des options est artificiellement élevée ce weekend (prime weekend des market makers) — les IVR affichés sont surestimés.\n"
|
||||||
|
)
|
||||||
|
elif market_session in ("pre_market", "after_hours", "overnight"):
|
||||||
|
session_banner = (
|
||||||
|
f"\n## 📅 STATUT DES MARCHÉS — {day_of_week} {market_session.upper()}\n"
|
||||||
|
f"{market_note}\n"
|
||||||
|
"→ Les prix peuvent ne pas refléter la session régulière — liquidité réduite.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
session_banner = f"\n## 📅 STATUT DES MARCHÉS — {day_of_week} | {market_session.upper()}\n{market_note}\n" if day_of_week else ""
|
||||||
|
|
||||||
block = f"""
|
block = f"""
|
||||||
|
{session_banner}
|
||||||
## ⏱ CONTEXTE TEMPOREL DU CYCLE
|
## ⏱ CONTEXTE TEMPOREL DU CYCLE
|
||||||
- Dernier cycle il y a : {delta_min:.0f} minutes
|
- Dernier cycle il y a : {delta_min:.0f} minutes
|
||||||
- Calibration : {calib}
|
- Calibration : {calib}
|
||||||
|
|||||||
@@ -230,12 +230,41 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
|
|||||||
else:
|
else:
|
||||||
_calib_label = f"Long terme ({_delta_minutes/60:.0f}h) — marchés ont eu le temps d'intégrer"
|
_calib_label = f"Long terme ({_delta_minutes/60:.0f}h) — marchés ont eu le temps d'intégrer"
|
||||||
|
|
||||||
|
# ── Market calendar context ────────────────────────────────────────
|
||||||
|
from datetime import timezone as _tz
|
||||||
|
_now_utc = _now.replace(tzinfo=_tz.utc) if _now.tzinfo is None else _now.astimezone(_tz.utc)
|
||||||
|
_weekday = _now_utc.weekday() # 0=Mon … 6=Sun
|
||||||
|
_day_names = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
|
||||||
|
_is_weekend = _weekday >= 5
|
||||||
|
# CME equity/energy/metals futures: closed Fri 17:00 ET → Sun 18:00 ET
|
||||||
|
# Rough ET offset (UTC-5 winter / UTC-4 summer) — good enough for intent
|
||||||
|
_et_hour = (_now_utc.hour - 5) % 24
|
||||||
|
if _is_weekend:
|
||||||
|
_market_session = "closed"
|
||||||
|
_market_note = f"{_day_names[_weekday]} — marchés US et CME fermés. Derniers prix disponibles: vendredi clôture."
|
||||||
|
elif _et_hour < 4:
|
||||||
|
_market_session = "overnight"
|
||||||
|
_market_note = "Nuit US (overnight) — liquidité réduite, spreads larges."
|
||||||
|
elif _et_hour < 9:
|
||||||
|
_market_session = "pre_market"
|
||||||
|
_market_note = "Pré-marché US — prix indicatifs, faible liquidité."
|
||||||
|
elif _et_hour < 16:
|
||||||
|
_market_session = "open"
|
||||||
|
_market_note = "Marché US ouvert."
|
||||||
|
else:
|
||||||
|
_market_session = "after_hours"
|
||||||
|
_market_note = "After-hours US — prix indicatifs, liquidité réduite."
|
||||||
|
|
||||||
cycle_meta = {
|
cycle_meta = {
|
||||||
"current_cycle_ts": _now.isoformat(),
|
"current_cycle_ts": _now.isoformat(),
|
||||||
"last_cycle_ts": _last_cycle_ts_str,
|
"last_cycle_ts": _last_cycle_ts_str,
|
||||||
"delta_minutes": round(_delta_minutes, 1),
|
"delta_minutes": round(_delta_minutes, 1),
|
||||||
"interval_hours": _interval_hours,
|
"interval_hours": _interval_hours,
|
||||||
"calibration_label": _calib_label,
|
"calibration_label": _calib_label,
|
||||||
|
"day_of_week": _day_names[_weekday],
|
||||||
|
"is_weekend": _is_weekend,
|
||||||
|
"market_session": _market_session,
|
||||||
|
"market_note": _market_note,
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Cycle {run_id[:16]}] Cycle meta: delta={_delta_minutes:.0f}min depuis dernier cycle"
|
f"[Cycle {run_id[:16]}] Cycle meta: delta={_delta_minutes:.0f}min depuis dernier cycle"
|
||||||
@@ -1075,6 +1104,10 @@ def _apply_iv_gate(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Detect weekend: IVR=100 on weekends is artificial (weekend premium) — ignore rank
|
||||||
|
from datetime import datetime as _dt_cls
|
||||||
|
_weekend_now = _dt_cls.utcnow().weekday() >= 5
|
||||||
|
|
||||||
all_blocked: List[Dict] = []
|
all_blocked: List[Dict] = []
|
||||||
|
|
||||||
for sp in scored:
|
for sp in scored:
|
||||||
@@ -1093,6 +1126,11 @@ def _apply_iv_gate(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
iv_rank = snap.get("iv_rank")
|
iv_rank = snap.get("iv_rank")
|
||||||
|
# On weekends, ATM IV is inflated by weekend premium (market makers can't hedge).
|
||||||
|
# IVR=99-100 on a weekend is almost always artificial — don't block on it.
|
||||||
|
if _weekend_now and iv_rank is not None and iv_rank >= 99.0:
|
||||||
|
iv_rank = None
|
||||||
|
snap = {**snap, "iv_rank": None}
|
||||||
iv_current_pct = snap.get("iv_current_pct")
|
iv_current_pct = snap.get("iv_current_pct")
|
||||||
iv_min_52w = snap.get("iv_min_52w_pct")
|
iv_min_52w = snap.get("iv_min_52w_pct")
|
||||||
iv_max_52w = snap.get("iv_max_52w_pct")
|
iv_max_52w = snap.get("iv_max_52w_pct")
|
||||||
|
|||||||
@@ -1069,6 +1069,45 @@ def _normalize_yf_ticker(ticker: str) -> str:
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# Common AI hallucinations / wrong ticker formats → canonical Yahoo Finance symbols
|
||||||
|
TICKER_ALIASES: dict = {
|
||||||
|
# Commodities
|
||||||
|
"WHEAT": "ZW=F", "CORN_FUTURES": "ZC=F", "SOYBEANS": "ZS=F", "SOYBEAN": "ZS=F",
|
||||||
|
"CRUDE": "CL=F", "OIL": "CL=F", "WTI": "CL=F", "BRENT": "BZ=F",
|
||||||
|
"CRUDE OIL": "CL=F", "NATURAL GAS": "NG=F",
|
||||||
|
"GOLD": "GC=F", "SILVER": "SI=F", "COPPER": "HG=F", "PLATINUM": "PL=F",
|
||||||
|
"SUGAR": "SB=F", "COFFEE": "KC=F", "COCOA": "CC=F", "COTTON": "CT=F",
|
||||||
|
# Forex — "/" format → yfinance format
|
||||||
|
"EUR/USD": "EURUSD=X", "USD/EUR": "EURUSD=X",
|
||||||
|
"USD/JPY": "USDJPY=X", "JPY/USD": "USDJPY=X",
|
||||||
|
"GBP/USD": "GBPUSD=X", "USD/GBP": "GBPUSD=X",
|
||||||
|
"USD/CHF": "USDCHF=X", "CHF/USD": "USDCHF=X",
|
||||||
|
"AUD/USD": "AUDUSD=X", "USD/CAD": "USDCAD=X",
|
||||||
|
# Indices
|
||||||
|
"SP500": "^GSPC", "S&P500": "^GSPC", "S&P 500": "^GSPC",
|
||||||
|
"NASDAQ": "QQQ", "NASDAQ100": "^NDX",
|
||||||
|
"DOW": "DIA", "DOW JONES": "DIA",
|
||||||
|
"RUSSELL2000": "IWM", "RUSSELL 2000": "IWM",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ticker(ticker: str) -> str:
|
||||||
|
"""Normalize AI-generated ticker strings to valid Yahoo Finance symbols."""
|
||||||
|
if not ticker:
|
||||||
|
return ticker
|
||||||
|
t = ticker.strip()
|
||||||
|
upper = t.upper()
|
||||||
|
# Check alias map (case-insensitive)
|
||||||
|
if upper in TICKER_ALIASES:
|
||||||
|
return TICKER_ALIASES[upper]
|
||||||
|
# Forex: "EUR/USD" style not caught above
|
||||||
|
if "/" in t:
|
||||||
|
parts = t.upper().split("/")
|
||||||
|
if len(parts) == 2 and all(2 <= len(p) <= 4 for p in parts):
|
||||||
|
return parts[0] + parts[1] + "=X"
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
_TICKER_ASSET_CLASS: dict = {
|
_TICKER_ASSET_CLASS: dict = {
|
||||||
# Energy
|
# Energy
|
||||||
"CL=F": "energy", "BZ=F": "energy", "NG=F": "energy", "RB=F": "energy", "HO=F": "energy",
|
"CL=F": "energy", "BZ=F": "energy", "NG=F": "energy", "RB=F": "energy", "HO=F": "energy",
|
||||||
|
|||||||
@@ -48,9 +48,21 @@ IV_WATCHLIST = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Common AI-hallucinated ticker names → canonical Yahoo Finance tickers
|
||||||
|
_TICKER_ALIASES: Dict[str, str] = {
|
||||||
|
"WHEAT": "ZW=F", "CORN_FUTURES": "ZC=F", "SOYBEANS": "ZS=F", "SOYBEAN": "ZS=F",
|
||||||
|
"CRUDE": "CL=F", "OIL": "CL=F", "WTI": "CL=F", "BRENT": "BZ=F",
|
||||||
|
"GOLD": "GC=F", "SILVER": "SI=F", "COPPER": "HG=F",
|
||||||
|
"SP500": "^GSPC", "S&P500": "^GSPC", "NASDAQ": "QQQ",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ticker(ticker: str) -> str:
|
def _resolve_ticker(ticker: str) -> str:
|
||||||
"""Return the optionable proxy ticker for a given symbol."""
|
"""Return the optionable proxy ticker for a given symbol."""
|
||||||
t = ticker.upper().strip()
|
t = ticker.upper().strip()
|
||||||
|
# Normalize alias names (WHEAT → ZW=F, etc.)
|
||||||
|
if t in _TICKER_ALIASES:
|
||||||
|
t = _TICKER_ALIASES[t]
|
||||||
# Normalize slash-format forex (EUR/USD → EURUSD=X) before proxy lookup
|
# Normalize slash-format forex (EUR/USD → EURUSD=X) before proxy lookup
|
||||||
if '/' in t:
|
if '/' in t:
|
||||||
parts = t.split('/')
|
parts = t.split('/')
|
||||||
@@ -412,8 +424,8 @@ def get_full_iv_snapshot(ticker: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
rank_data: Dict[str, Any] = {}
|
rank_data: Dict[str, Any] = {}
|
||||||
if iv_current:
|
if iv_current:
|
||||||
if live_iv:
|
if live_iv and date.today().weekday() < 5:
|
||||||
# Only persist to history when we have a fresh live IV
|
# Only persist weekday IV — weekend premium inflates IV and corrupts history
|
||||||
save_iv_snapshot(proxy, today, iv_current, term.get("iv_30d"), term.get("iv_60d"), term.get("iv_90d"))
|
save_iv_snapshot(proxy, today, iv_current, term.get("iv_30d"), term.get("iv_60d"), term.get("iv_90d"))
|
||||||
rank_data = get_iv_rank_percentile(proxy, iv_current)
|
rank_data = get_iv_rank_percentile(proxy, iv_current)
|
||||||
|
|
||||||
@@ -498,7 +510,9 @@ def get_iv_context_for_prompt(tickers: List[str]) -> str:
|
|||||||
continue
|
continue
|
||||||
from services.database import get_iv_rank_percentile, save_iv_snapshot
|
from services.database import get_iv_rank_percentile, save_iv_snapshot
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
save_iv_snapshot(proxy, today, iv, None, None, None)
|
# Don't save weekend IV — market premium inflates it, would corrupt history
|
||||||
|
if date.today().weekday() < 5:
|
||||||
|
save_iv_snapshot(proxy, today, iv, None, None, None)
|
||||||
rank = get_iv_rank_percentile(proxy, iv)
|
rank = get_iv_rank_percentile(proxy, iv)
|
||||||
|
|
||||||
iv_rank = rank.get("iv_rank")
|
iv_rank = rank.get("iv_rank")
|
||||||
|
|||||||
@@ -105,13 +105,16 @@ def compute_indicators(ticker: str, horizon_days: int, enabled_indicators: Optio
|
|||||||
lookback = cal["ma_slow"] * 2 + 50
|
lookback = cal["ma_slow"] * 2 + 50
|
||||||
try:
|
try:
|
||||||
df = yf.download(ticker, period=f"{lookback}d", interval="1d", progress=False, auto_adjust=True)
|
df = yf.download(ticker, period=f"{lookback}d", interval="1d", progress=False, auto_adjust=True)
|
||||||
|
# yfinance ≥0.2 returns MultiIndex columns when group_by is not set — flatten
|
||||||
|
if df is not None and isinstance(df.columns, pd.MultiIndex):
|
||||||
|
df.columns = df.columns.droplevel(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"yfinance download failed: {e}"}
|
return {"error": f"yfinance download failed: {e}"}
|
||||||
|
|
||||||
if df is None or len(df) < cal["ma_slow"]:
|
if df is None or len(df) < cal["ma_slow"]:
|
||||||
return {"error": f"Not enough data for {ticker} (got {len(df) if df is not None else 0} rows)"}
|
return {"error": f"Not enough data for {ticker} (got {len(df) if df is not None else 0} rows)"}
|
||||||
|
|
||||||
closes = df["Close"].dropna()
|
closes = df["Close"].squeeze().dropna()
|
||||||
price = float(closes.iloc[-1])
|
price = float(closes.iloc[-1])
|
||||||
enabled = set(enabled_indicators) if enabled_indicators else {"rsi", "ma", "bollinger", "atr"}
|
enabled = set(enabled_indicators) if enabled_indicators else {"rsi", "ma", "bollinger", "atr"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user