Backend (pattern_lab.py): - Replace binary HIT/MISS with FULL / PARTIAL / MISS scoring FULL: right direction AND ≥ 50% of expected move PARTIAL: right direction AND ≥ 15% of expected move (was always MISS before) MISS: wrong direction or negligible move - Add direction_correct, direction_ratio, hit_type fields to all outcomes - Add Black-Scholes ATM options P&L simulation (_bs_price, _ncdf, _sigma_for) Normalised to S₀=K=100, per-asset-class vol heuristic (FX 8%, indices 16%, crypto 65%) Supports: long call/put, straddle, strangle, call spread, put spread - estimated_options_pnl_pct shows what the strategy would have returned Frontend (PatternLab.tsx): - OutcomeRow component: FULL HIT (green) / PARTIAL (amber) / MISS (red) - Shows direction tick/cross + ratio % of target achieved - Shows estimated options P&L with DollarSign icon - Hit rate header shows full hits + partial count separately - Card border: emerald = full, amber = partial, red = miss Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
556 lines
23 KiB
Python
556 lines
23 KiB
Python
"""
|
||
Pattern Lab — Historical backtest engine for pattern discovery.
|
||
|
||
Workflow:
|
||
1. build_historical_context(date, tickers) — pull prices + technicals via yfinance
|
||
2. run_ai_backtest(context, hint, horizon) — GPT-4o acts as analyst on that past date
|
||
3. evaluate_outcomes(run) — fetch actual prices at T+horizon, score each idea
|
||
"""
|
||
import json
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from math import log, sqrt, exp, erf
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import yfinance as yf
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
|
||
# ── Options P&L helpers ────────────────────────────────────────────────────────
|
||
|
||
def _ncdf(x: float) -> float:
|
||
return 0.5 * (1.0 + erf(x / sqrt(2.0)))
|
||
|
||
|
||
def _bs_price(S: float, K: float, T: float, r: float, sigma: float, opt_type: str) -> float:
|
||
"""Black-Scholes European option price. T in years. At expiry (T≤0) returns intrinsic."""
|
||
if T <= 0:
|
||
return max(S - K, 0.0) if opt_type == "call" else max(K - S, 0.0)
|
||
d1 = (log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt(T))
|
||
d2 = d1 - sigma * sqrt(T)
|
||
if opt_type == "call":
|
||
return S * _ncdf(d1) - K * exp(-r * T) * _ncdf(d2)
|
||
return K * exp(-r * T) * _ncdf(-d2) - S * _ncdf(-d1)
|
||
|
||
|
||
def _sigma_for(ticker: str) -> float:
|
||
"""Rough annualised vol estimate by asset class (ATM premium sizing)."""
|
||
t = ticker.upper()
|
||
if t.endswith("=X"): return 0.08 # FX pairs
|
||
if "VIX" in t: return 0.80 # volatility index
|
||
if t.startswith("^"): return 0.16 # equity indices
|
||
if t.endswith("-USD") or "-USD" in t: return 0.65 # crypto
|
||
if t.endswith("=F"): return 0.25 # commodity futures
|
||
return 0.20 # ETFs / default
|
||
|
||
|
||
def _options_pnl_pct(
|
||
strategy: str, ticker: str,
|
||
actual_move_pct: float, horizon_days: int,
|
||
expected_direction: str,
|
||
) -> Optional[float]:
|
||
"""
|
||
Estimate % P&L on the suggested options strategy assuming ATM entry,
|
||
full-horizon hold, and actual_move_pct underlying move by expiry.
|
||
Normalised to S₀ = K = 100.
|
||
"""
|
||
try:
|
||
sigma = _sigma_for(ticker)
|
||
T = max(horizon_days / 365.0, 1 / 365.0)
|
||
r = 0.03
|
||
S0 = 100.0
|
||
K = 100.0
|
||
S1 = S0 * (1.0 + actual_move_pct / 100.0)
|
||
strat = strategy.lower()
|
||
|
||
if "straddle" in strat:
|
||
cost = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")
|
||
elif "strangle" in strat:
|
||
Kc, Kp = K * 1.05, K * 0.95
|
||
cost = _bs_price(S0, Kc, T, r, sigma, "call") + _bs_price(S0, Kp, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, Kc, 0, r, sigma, "call") + _bs_price(S1, Kp, 0, r, sigma, "put")
|
||
elif "call spread" in strat or "bull call" in strat:
|
||
Kh = K * 1.10
|
||
cost = _bs_price(S0, K, T, r, sigma, "call") - _bs_price(S0, Kh, T, r, sigma, "call")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "call") - _bs_price(S1, Kh, 0, r, sigma, "call")
|
||
elif "put spread" in strat or "bear put" in strat:
|
||
Kl = K * 0.90
|
||
cost = _bs_price(S0, K, T, r, sigma, "put") - _bs_price(S0, Kl, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "put") - _bs_price(S1, Kl, 0, r, sigma, "put")
|
||
elif "long call" in strat or ("call" in strat and "put" not in strat):
|
||
cost = _bs_price(S0, K, T, r, sigma, "call")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "call")
|
||
elif "long put" in strat or ("put" in strat and "call" not in strat):
|
||
cost = _bs_price(S0, K, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "put")
|
||
else:
|
||
# Unknown strategy: fallback based on direction
|
||
if expected_direction == "down":
|
||
cost = _bs_price(S0, K, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "put")
|
||
elif expected_direction == "up":
|
||
cost = _bs_price(S0, K, T, r, sigma, "call")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "call")
|
||
else:
|
||
cost = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
|
||
exit_v = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")
|
||
|
||
if cost <= 0.01:
|
||
return None
|
||
return round((exit_v - cost) / cost * 100.0, 1)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _score_outcome(
|
||
actual_move: float,
|
||
expected_move: float,
|
||
expected_dir: str,
|
||
strategy: str,
|
||
ticker: str,
|
||
horizon_days: int,
|
||
) -> dict:
|
||
"""
|
||
3-tier scoring:
|
||
FULL — right direction AND ≥ 50% of expected magnitude
|
||
PARTIAL — right direction AND ≥ 15% of expected magnitude
|
||
MISS — wrong direction or negligible move
|
||
`hit` (bool) = True only for FULL (for backtest_hits counter compatibility).
|
||
"""
|
||
em = abs(float(expected_move))
|
||
|
||
if expected_dir == "up":
|
||
direction_correct = actual_move > 0
|
||
direction_ratio = (actual_move / em) if em > 0 else 0.0
|
||
elif expected_dir == "down":
|
||
direction_correct = actual_move < 0
|
||
direction_ratio = (-actual_move / em) if em > 0 else 0.0
|
||
else: # "any" / volatility
|
||
direction_correct = True
|
||
direction_ratio = (abs(actual_move) / em) if em > 0 else 0.0
|
||
|
||
direction_ratio = round(direction_ratio, 3)
|
||
|
||
if direction_correct and direction_ratio >= 0.50:
|
||
hit_type = "full"
|
||
elif direction_correct and direction_ratio >= 0.15:
|
||
hit_type = "partial"
|
||
else:
|
||
hit_type = "miss"
|
||
|
||
options_pnl = _options_pnl_pct(strategy, ticker, actual_move, horizon_days, expected_dir)
|
||
|
||
return {
|
||
"hit": hit_type == "full",
|
||
"hit_type": hit_type,
|
||
"direction_correct": direction_correct,
|
||
"direction_ratio": direction_ratio,
|
||
"estimated_options_pnl_pct": options_pnl,
|
||
}
|
||
|
||
|
||
# ── Context builder ────────────────────────────────────────────────────────────
|
||
|
||
def _fetch_ticker(ticker: str, start: str, end: str) -> Optional[pd.DataFrame]:
|
||
try:
|
||
df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
|
||
if isinstance(df.columns, pd.MultiIndex):
|
||
df.columns = df.columns.get_level_values(0)
|
||
return df if not df.empty else None
|
||
except Exception as e:
|
||
_log.warning(f"[PatternLab] yfinance error for {ticker}: {e}")
|
||
return None
|
||
|
||
|
||
def _rsi(closes: pd.Series, period: int = 14) -> Optional[float]:
|
||
if len(closes) < period + 1:
|
||
return None
|
||
delta = closes.diff()
|
||
gain = delta.clip(lower=0).rolling(period).mean()
|
||
loss = (-delta.clip(upper=0)).rolling(period).mean()
|
||
rs = gain / loss
|
||
val = 100 - (100 / (1 + rs.iloc[-1]))
|
||
return round(float(val), 1) if not np.isnan(val) else None
|
||
|
||
|
||
def build_historical_context(analysis_date: str, tickers: list) -> dict:
|
||
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
|
||
hist_start = (dt - timedelta(days=400)).strftime("%Y-%m-%d")
|
||
hist_end = (dt + timedelta(days=3)).strftime("%Y-%m-%d")
|
||
|
||
context: dict = {"analysis_date": analysis_date, "assets": {}}
|
||
|
||
all_tickers = list(tickers)
|
||
if "^VIX" not in all_tickers:
|
||
all_tickers.append("^VIX")
|
||
|
||
for ticker in all_tickers:
|
||
df = _fetch_ticker(ticker, hist_start, hist_end)
|
||
if df is None or "Close" not in df.columns:
|
||
context["assets"][ticker] = {"error": "no data"}
|
||
continue
|
||
|
||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||
sub = df[df.index.normalize() <= dt].copy()
|
||
if sub.empty:
|
||
context["assets"][ticker] = {"error": "no data before date"}
|
||
continue
|
||
|
||
closes = sub["Close"].dropna()
|
||
price = float(closes.iloc[-1])
|
||
|
||
def ret(days: int) -> Optional[float]:
|
||
past = sub[sub.index.normalize() <= dt - timedelta(days=days)]
|
||
if past.empty:
|
||
return None
|
||
p0 = float(past["Close"].dropna().iloc[-1])
|
||
return round((price / p0 - 1) * 100, 2) if p0 else None
|
||
|
||
ma50 = round(float(closes.tail(50).mean()), 2) if len(closes) >= 50 else None
|
||
ma200 = round(float(closes.tail(200).mean()), 2) if len(closes) >= 200 else None
|
||
|
||
context["assets"][ticker] = {
|
||
"price": round(price, 4),
|
||
"ret_1w": ret(5),
|
||
"ret_1m": ret(21),
|
||
"ret_3m": ret(63),
|
||
"ret_ytd": ret(int((dt - datetime(dt.year, 1, 1)).days)),
|
||
"rsi_14": _rsi(closes.tail(60)),
|
||
"ma50": ma50,
|
||
"ma200": ma200,
|
||
"pct_from_ma200": round((price / ma200 - 1) * 100, 2) if ma200 else None,
|
||
}
|
||
|
||
return context
|
||
|
||
|
||
# ── AI analysis ────────────────────────────────────────────────────────────────
|
||
|
||
async def run_ai_backtest(context: dict, theme_hint: str, horizon_days: int, openai_key: str) -> dict:
|
||
from openai import AsyncOpenAI
|
||
client = AsyncOpenAI(api_key=openai_key)
|
||
|
||
analysis_date = context["analysis_date"]
|
||
assets = context.get("assets", {})
|
||
|
||
lines = []
|
||
for ticker, d in assets.items():
|
||
if "error" in d:
|
||
continue
|
||
line = f" {ticker}: {d.get('price', '?')}"
|
||
if d.get("ret_1w") is not None: line += f" 1W:{d['ret_1w']:+.1f}%"
|
||
if d.get("ret_1m") is not None: line += f" 1M:{d['ret_1m']:+.1f}%"
|
||
if d.get("ret_3m") is not None: line += f" 3M:{d['ret_3m']:+.1f}%"
|
||
if d.get("rsi_14") is not None: line += f" RSI:{d['rsi_14']:.0f}"
|
||
if d.get("pct_from_ma200") is not None: line += f" vsMA200:{d['pct_from_ma200']:+.1f}%"
|
||
lines.append(line)
|
||
|
||
market_block = "\n".join(lines) if lines else "No market data available."
|
||
|
||
prompt = f"""You are a senior macro/options trader. Today is {analysis_date}.
|
||
You have full knowledge of what was happening in markets and geopolitics on this specific date.
|
||
|
||
## REAL MARKET DATA — {analysis_date}
|
||
{market_block}
|
||
|
||
## CONTEXT / THEME
|
||
{theme_hint}
|
||
|
||
## INSTRUCTIONS
|
||
Using the market data above AND your knowledge of the historical context on {analysis_date}:
|
||
- Identify 2 to 4 high-conviction trade patterns that were present at this time.
|
||
- For each pattern, recommend the best options/futures strategy.
|
||
- The investment horizon is {horizon_days} days from {analysis_date}.
|
||
|
||
For each pattern you MUST specify:
|
||
- The primary underlying ticker (must be a real, liquid, yfinance-compatible symbol)
|
||
- The expected percentage move in the UNDERLYING over {horizon_days} days
|
||
- The expected direction: "up", "down", or "any" (for volatility plays)
|
||
- A confidence score 0–100
|
||
|
||
Return ONLY this valid JSON (no markdown, no explanation):
|
||
{{
|
||
"patterns": [
|
||
{{
|
||
"name": "Short descriptive pattern name",
|
||
"description": "What you observed and why this matters",
|
||
"category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
|
||
"signal_direction": "bullish|bearish|volatility|neutral",
|
||
"underlying": "TICKER",
|
||
"strategy": "e.g. long put, long call, straddle, call spread",
|
||
"expected_move_pct": 12.5,
|
||
"expected_direction": "up|down|any",
|
||
"horizon_days": {horizon_days},
|
||
"confidence": 72,
|
||
"rationale": "Concise explanation of the trade thesis at this historical moment"
|
||
}}
|
||
]
|
||
}}"""
|
||
|
||
try:
|
||
resp = await client.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=0.2,
|
||
response_format={"type": "json_object"},
|
||
timeout=60,
|
||
)
|
||
raw = resp.choices[0].message.content or "{}"
|
||
return json.loads(raw)
|
||
except Exception as e:
|
||
_log.error(f"[PatternLab] AI backtest failed: {e}")
|
||
return {"patterns": [], "error": str(e)}
|
||
|
||
|
||
# ── Outcome evaluator ──────────────────────────────────────────────────────────
|
||
|
||
def evaluate_outcomes(run: dict) -> list:
|
||
analysis_date = run["analysis_date"]
|
||
horizon_days = int(run["horizon_days"])
|
||
ai_result = json.loads(run.get("ai_result") or "{}")
|
||
patterns = ai_result.get("patterns", [])
|
||
|
||
dt = datetime.strptime(analysis_date, "%Y-%m-%d")
|
||
eval_dt = dt + timedelta(days=horizon_days)
|
||
# Don't evaluate if eval date is in the future
|
||
if eval_dt.date() >= datetime.utcnow().date():
|
||
return [{"error": f"Evaluation date {eval_dt.date()} is in the future"}]
|
||
|
||
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
|
||
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
|
||
|
||
outcomes = []
|
||
for pat in patterns:
|
||
ticker = pat.get("underlying", "")
|
||
if not ticker:
|
||
continue
|
||
|
||
df = _fetch_ticker(ticker, fetch_start, fetch_end)
|
||
if df is None or "Close" not in df.columns:
|
||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "no data"})
|
||
continue
|
||
|
||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||
|
||
entry_sub = df[df.index.normalize() <= dt]
|
||
exit_sub = df[df.index.normalize() <= eval_dt]
|
||
if entry_sub.empty or exit_sub.empty:
|
||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "insufficient history"})
|
||
continue
|
||
|
||
entry_price = float(entry_sub["Close"].dropna().iloc[-1])
|
||
exit_price = float(exit_sub["Close"].dropna().iloc[-1])
|
||
actual_move = round((exit_price / entry_price - 1) * 100, 2)
|
||
|
||
expected_dir = pat.get("expected_direction", "any")
|
||
expected_move = float(pat.get("expected_move_pct", 0))
|
||
strategy = pat.get("strategy", "")
|
||
|
||
score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, horizon_days)
|
||
|
||
outcomes.append({
|
||
"pattern_name": pat.get("name"),
|
||
"underlying": ticker,
|
||
"strategy": strategy,
|
||
"signal_direction": pat.get("signal_direction", ""),
|
||
"expected_direction": expected_dir,
|
||
"expected_move_pct": expected_move,
|
||
"actual_move_pct": actual_move,
|
||
"entry_price": round(entry_price, 4),
|
||
"exit_price": round(exit_price, 4),
|
||
"entry_date": analysis_date,
|
||
"exit_date": eval_dt.strftime("%Y-%m-%d"),
|
||
"confidence": pat.get("confidence", 0),
|
||
**score,
|
||
})
|
||
|
||
return outcomes
|
||
|
||
|
||
# ── Instrument Scan ────────────────────────────────────────────────────────────
|
||
|
||
async def run_instrument_scan(
|
||
ticker: str, instrument_name: str,
|
||
start_date: str, end_date: str,
|
||
horizon_days: int, openai_key: str,
|
||
) -> dict:
|
||
"""
|
||
Analyse a specific instrument over a historical period.
|
||
The AI identifies 4-6 key pattern instances (each with its own entry date)
|
||
that drove significant moves in the underlying.
|
||
"""
|
||
from openai import AsyncOpenAI
|
||
client = AsyncOpenAI(api_key=openai_key)
|
||
|
||
# Fetch full period price history
|
||
fetch_start = (datetime.strptime(start_date, "%Y-%m-%d") - timedelta(days=5)).strftime("%Y-%m-%d")
|
||
fetch_end = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=5)).strftime("%Y-%m-%d")
|
||
df = _fetch_ticker(ticker, fetch_start, fetch_end)
|
||
|
||
monthly_lines = []
|
||
weekly_spikes = []
|
||
if df is not None and "Close" in df.columns:
|
||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||
s_dt = pd.Timestamp(start_date)
|
||
e_dt = pd.Timestamp(end_date)
|
||
sub = df[(df.index >= s_dt) & (df.index <= e_dt)].copy()
|
||
|
||
if not sub.empty:
|
||
# Monthly returns
|
||
monthly = sub["Close"].resample("ME").last().dropna()
|
||
for i in range(1, len(monthly)):
|
||
ret = (monthly.iloc[i] / monthly.iloc[i - 1] - 1) * 100
|
||
monthly_lines.append(f" {monthly.index[i].strftime('%Y-%m')}: {ret:+.1f}% (price {monthly.iloc[i]:.2f})")
|
||
|
||
# Weekly moves > 3%
|
||
weekly = sub["Close"].resample("W").last().dropna()
|
||
for i in range(1, len(weekly)):
|
||
wret = (weekly.iloc[i] / weekly.iloc[i - 1] - 1) * 100
|
||
if abs(wret) >= 3.0:
|
||
weekly_spikes.append(
|
||
f" week of {weekly.index[i].strftime('%Y-%m-%d')}: {wret:+.1f}%"
|
||
)
|
||
|
||
monthly_block = "\n".join(monthly_lines) if monthly_lines else " (no monthly data)"
|
||
spikes_block = "\n".join(weekly_spikes[:20]) if weekly_spikes else " (none > 3%)"
|
||
|
||
prompt = f"""You are a senior macro/options trader with deep historical market knowledge.
|
||
|
||
Instrument: {instrument_name} [{ticker}]
|
||
Period: {start_date} → {end_date}
|
||
|
||
## ACTUAL MONTHLY PRICE RETURNS
|
||
{monthly_block}
|
||
|
||
## SIGNIFICANT WEEKLY MOVES (>3%)
|
||
{spikes_block}
|
||
|
||
## YOUR TASK
|
||
Based on the actual price data above and your historical knowledge of this period:
|
||
1. Identify 4 to 6 DISTINCT pattern/event instances that drove the most significant moves in {instrument_name}
|
||
2. Each pattern must have a specific ENTRY DATE (the trading signal date) within the period
|
||
3. Each pattern must be unique — different catalysts on different dates
|
||
4. The trade horizon per pattern is {horizon_days} days
|
||
|
||
For each pattern instance you MUST specify:
|
||
- `analysis_date`: the exact signal/entry date (YYYY-MM-DD, must be within {start_date} to {end_date})
|
||
- `underlying`: use "{ticker}" as the underlying
|
||
- `expected_direction`: "up", "down", or "any" (for volatility)
|
||
- `expected_move_pct`: realistic % move over {horizon_days} days (consistent with what actually happened)
|
||
- `confidence`: 0-100
|
||
|
||
Return ONLY this valid JSON:
|
||
{{
|
||
"instrument": "{instrument_name}",
|
||
"ticker": "{ticker}",
|
||
"period": "{start_date} to {end_date}",
|
||
"patterns": [
|
||
{{
|
||
"name": "Pattern name",
|
||
"description": "What caused this move and why it was tradeable",
|
||
"category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
|
||
"signal_direction": "bullish|bearish|volatility|neutral",
|
||
"analysis_date": "YYYY-MM-DD",
|
||
"underlying": "{ticker}",
|
||
"strategy": "long call|long put|straddle|call spread|put spread|...",
|
||
"expected_direction": "up|down|any",
|
||
"expected_move_pct": 5.0,
|
||
"horizon_days": {horizon_days},
|
||
"confidence": 75,
|
||
"rationale": "Why this was a high-conviction trade at this specific moment"
|
||
}}
|
||
]
|
||
}}"""
|
||
|
||
try:
|
||
resp = await client.chat.completions.create(
|
||
model="gpt-4o",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=0.2,
|
||
response_format={"type": "json_object"},
|
||
timeout=90,
|
||
)
|
||
raw = resp.choices[0].message.content or "{}"
|
||
return json.loads(raw)
|
||
except Exception as e:
|
||
_log.error(f"[PatternLab] Instrument scan AI failed: {e}")
|
||
return {"patterns": [], "error": str(e)}
|
||
|
||
|
||
def evaluate_instrument_outcomes(run: dict) -> list:
|
||
"""
|
||
Like evaluate_outcomes but each pattern has its own analysis_date.
|
||
Used for instrument scans where the AI generates time-distributed pattern instances.
|
||
"""
|
||
ai_result = json.loads(run.get("ai_result") or "{}")
|
||
patterns = ai_result.get("patterns", [])
|
||
default_horizon = int(run["horizon_days"])
|
||
|
||
outcomes = []
|
||
for pat in patterns:
|
||
ticker = pat.get("underlying", "")
|
||
pat_date = pat.get("analysis_date") or run["analysis_date"]
|
||
pat_horizon = int(pat.get("horizon_days") or default_horizon)
|
||
if not ticker or not pat_date:
|
||
continue
|
||
|
||
try:
|
||
dt = datetime.strptime(pat_date, "%Y-%m-%d")
|
||
eval_dt = dt + timedelta(days=pat_horizon)
|
||
except ValueError:
|
||
continue
|
||
|
||
if eval_dt.date() >= datetime.utcnow().date():
|
||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
|
||
"analysis_date": pat_date, "error": "eval date in the future"})
|
||
continue
|
||
|
||
fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
|
||
fetch_end = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
|
||
df = _fetch_ticker(ticker, fetch_start, fetch_end)
|
||
if df is None or "Close" not in df.columns:
|
||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
|
||
"analysis_date": pat_date, "error": "no data"})
|
||
continue
|
||
|
||
df.index = pd.to_datetime(df.index).tz_localize(None)
|
||
entry_sub = df[df.index.normalize() <= dt]
|
||
exit_sub = df[df.index.normalize() <= eval_dt]
|
||
if entry_sub.empty or exit_sub.empty:
|
||
outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
|
||
"analysis_date": pat_date, "error": "insufficient history"})
|
||
continue
|
||
|
||
entry_price = float(entry_sub["Close"].dropna().iloc[-1])
|
||
exit_price = float(exit_sub["Close"].dropna().iloc[-1])
|
||
actual_move = round((exit_price / entry_price - 1) * 100, 2)
|
||
|
||
expected_dir = pat.get("expected_direction", "any")
|
||
expected_move = float(pat.get("expected_move_pct", 0))
|
||
strategy = pat.get("strategy", "")
|
||
|
||
score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, pat_horizon)
|
||
|
||
outcomes.append({
|
||
"pattern_name": pat.get("name"),
|
||
"underlying": ticker,
|
||
"strategy": strategy,
|
||
"signal_direction": pat.get("signal_direction", ""),
|
||
"expected_direction": expected_dir,
|
||
"expected_move_pct": expected_move,
|
||
"actual_move_pct": actual_move,
|
||
"entry_price": round(entry_price, 4),
|
||
"exit_price": round(exit_price, 4),
|
||
"analysis_date": pat_date,
|
||
"entry_date": pat_date,
|
||
"exit_date": eval_dt.strftime("%Y-%m-%d"),
|
||
"confidence": pat.get("confidence", 0),
|
||
**score,
|
||
})
|
||
|
||
return outcomes
|