@@ -766,6 +766,95 @@ def fetch_fx_data(pairs: List[str], days: int = 200) -> pd.DataFrame:
766766 return pd .DataFrame (rows )
767767
768768
769+ # ============================================================
770+ # OPTIONS CHAIN FETCHER
771+ # ============================================================
772+ def fetch_options_data (symbols : List [str ]) -> pd .DataFrame :
773+ """Fetch options chains via yfinance and convert to DataFrame for options strategies.
774+
775+ Returns a DataFrame with columns needed by DeepSurrogate, TDGF, and VolArb:
776+ symbol, strike, spot, expiry, moneyness, tau, rate, implied_vol, delta,
777+ bid, ask, volume, open_interest, option_type, kappa, theta, sigma, rho, v0.
778+ """
779+ try :
780+ today = date .today ()
781+ rows = []
782+ for symbol in symbols :
783+ try :
784+ ticker = yfinance .Ticker (symbol )
785+ # Get spot price
786+ hist = ticker .history (period = "1d" )
787+ if hist .empty :
788+ continue
789+ spot = float (hist ["Close" ].iloc [- 1 ])
790+ if spot <= 0 :
791+ continue
792+
793+ expiry_strings = ticker .options
794+ if not expiry_strings :
795+ continue
796+
797+ for exp_str in expiry_strings :
798+ exp_date = datetime .strptime (exp_str , "%Y-%m-%d" ).date ()
799+ dte = (exp_date - today ).days
800+ if dte < 7 or dte > 60 :
801+ continue
802+ tau = max (dte / 365.0 , 1e-6 )
803+
804+ try :
805+ chain = ticker .option_chain (exp_str )
806+ except Exception :
807+ continue
808+
809+ for side_df , opt_type in [(chain .calls , "call" ), (chain .puts , "put" )]:
810+ if side_df is None or side_df .empty :
811+ continue
812+ for _ , row in side_df .iterrows ():
813+ strike = float (row .get ("strike" , 0 ))
814+ if strike <= 0 :
815+ continue
816+ iv = float (row .get ("impliedVolatility" , 0 ) or 0 )
817+ rows .append ({
818+ "symbol" : symbol ,
819+ "strike" : strike ,
820+ "spot" : spot ,
821+ "expiry" : exp_date ,
822+ "moneyness" : strike / spot ,
823+ "tau" : tau ,
824+ "dte" : dte ,
825+ "rate" : 0.05 ,
826+ "implied_vol" : iv ,
827+ "bid" : float (row .get ("bid" , 0 ) or 0 ),
828+ "ask" : float (row .get ("ask" , 0 ) or 0 ),
829+ "last" : float (row .get ("lastPrice" , 0 ) or 0 ),
830+ "volume" : int (row .get ("volume" , 0 ) or 0 ),
831+ "open_interest" : int (row .get ("openInterest" , 0 ) or 0 ),
832+ "option_type" : opt_type ,
833+ # Default Heston params (model will calibrate from IV)
834+ "kappa" : 2.0 ,
835+ "theta" : 0.04 ,
836+ "sigma" : max (iv , 0.01 ) if iv > 0 else 0.20 ,
837+ "rho" : - 0.7 ,
838+ "v0" : max (iv ** 2 , 1e-4 ) if iv > 0 else 0.04 ,
839+ })
840+ except Exception as e :
841+ logger .debug ("Options fetch failed for %s: %s" , symbol , e )
842+
843+ if not rows :
844+ logger .info ("No options data fetched" )
845+ return pd .DataFrame ()
846+
847+ df = pd .DataFrame (rows )
848+ logger .info (
849+ "Fetched options data: %d contracts across %d symbols" ,
850+ len (df ), df ["symbol" ].nunique (),
851+ )
852+ return df
853+ except Exception as e :
854+ logger .error ("Options data fetch failed: %s" , e )
855+ return pd .DataFrame ()
856+
857+
769858# ============================================================
770859# STRATEGY RUNNER (dynamic registry)
771860# ============================================================
@@ -881,10 +970,14 @@ async def run_daily_pipeline(is_manual: bool = False):
881970 except Exception :
882971 pass
883972
884- # 1. Fetch data
885- stock_data = fetch_stock_data (TRADING_SYMBOLS )
973+ # 1. Fetch data (include FX ETFs for order execution)
974+ _FX_ETFS = ["FXE" , "FXB" , "FXY" , "FXA" , "FXC" , "FXF" ]
975+ stock_data = fetch_stock_data (TRADING_SYMBOLS + _FX_ETFS )
886976 fx_data = fetch_fx_data (FX_PAIRS )
887977
978+ # Fetch options chains for options strategies (deep_surrogates, tdgf, vol_arb)
979+ options_data = fetch_options_data (TRADING_SYMBOLS )
980+
888981 if stock_data .empty :
889982 logger .error ("No stock data fetched, aborting" )
890983 return
@@ -957,16 +1050,19 @@ async def run_daily_pipeline(is_manual: bool = False):
9571050
9581051 strategy_outputs = []
9591052 fx_strategies = {"fx_carry_trend" , "fx_momentum" , "fx_vol_breakout" }
1053+ options_strategies = {"deep_surrogates" , "tdgf" }
9601054
9611055 for strat_name , strategy in strategy_map .items ():
962- # Use FX data for FX strategies, stock data otherwise
963- input_data = (
964- fx_data
965- if strat_name in fx_strategies and not fx_data .empty
966- else stock_data
967- )
968- if strat_name in fx_strategies and fx_data .empty :
969- continue
1056+ # Route data: FX strategies get fx_data, options strategies get
1057+ # options_data (with stock_data fallback), others get stock_data
1058+ if strat_name in fx_strategies :
1059+ if fx_data .empty :
1060+ continue
1061+ input_data = fx_data
1062+ elif strat_name in options_strategies and not options_data .empty :
1063+ input_data = options_data
1064+ else :
1065+ input_data = stock_data
9701066
9711067 output = _run_strategy (strat_name , strategy , input_data , today )
9721068 if output is not None :
@@ -1220,6 +1316,11 @@ async def run_daily_pipeline(is_manual: bool = False):
12201316 current_holdings = {}
12211317
12221318 trades_executed = 0
1319+ logger .info (
1320+ "Order loop: %d target positions: %s" ,
1321+ len (target .positions ),
1322+ [f"{ p .symbol } :{ p .target_weight :.4f} " for p in target .positions ],
1323+ )
12231324 for pos in target .positions :
12241325 # GUARDRAIL: Execution failure rate check before each order
12251326 exec_health = exec_monitor .check ()
@@ -1245,15 +1346,45 @@ async def run_daily_pipeline(is_manual: bool = False):
12451346 target_value = pos .target_weight * state .portfolio_value
12461347 current_qty = current_holdings .get (symbol , 0 )
12471348
1248- # Get latest price
1249- sym_data = stock_data [stock_data ["symbol" ] == symbol ]
1349+ # FX pair → CurrencyShares ETF mapping for Alpaca execution
1350+ _FX_ETF_MAP = {
1351+ "EURUSD" : ("FXE" , False ), # long EURUSD = long FXE
1352+ "GBPUSD" : ("FXB" , False ),
1353+ "AUDUSD" : ("FXA" , False ),
1354+ "USDJPY" : ("FXY" , True ), # long USDJPY = short FXY
1355+ "USDCAD" : ("FXC" , True ),
1356+ "USDCHF" : ("FXF" , True ),
1357+ }
1358+
1359+ is_fx = symbol in _FX_ETF_MAP
1360+ trade_symbol = symbol
1361+ flip_side = False
1362+
1363+ if is_fx :
1364+ etf_sym , flip_side = _FX_ETF_MAP [symbol ]
1365+ trade_symbol = etf_sym
1366+ # Use ETF holdings for position diff
1367+ current_qty = current_holdings .get (etf_sym , 0 )
1368+
1369+ # Get latest price (check stock_data first, then fx_data)
1370+ sym_data = stock_data [stock_data ["symbol" ] == trade_symbol ]
1371+ if sym_data .empty and not fx_data .empty :
1372+ sym_data = fx_data [fx_data ["symbol" ] == symbol ]
12501373 if sym_data .empty :
1374+ logger .debug ("Skipping %s: no price data found" , symbol )
12511375 continue
12521376 price = float (sym_data .sort_values ("timestamp" )["close" ].iloc [- 1 ])
12531377 if price <= 0 :
12541378 continue
12551379
1380+ # Cap FX ETF positions at $2000 max
1381+ _FX_ETF_MAX_VALUE = 2000.0
1382+ if is_fx :
1383+ target_value = max (- _FX_ETF_MAX_VALUE , min (_FX_ETF_MAX_VALUE , target_value ))
1384+
12561385 target_shares = int (target_value / price )
1386+ if is_fx and flip_side :
1387+ target_shares = - target_shares
12571388 diff = target_shares - current_qty
12581389
12591390 if abs (diff ) < 1 :
@@ -1262,6 +1393,19 @@ async def run_daily_pipeline(is_manual: bool = False):
12621393 side = "buy" if diff > 0 else "sell"
12631394 qty = abs (diff )
12641395
1396+ if is_fx :
1397+ symbol = trade_symbol # Submit order with ETF ticker
1398+
1399+ # Cap sell qty at current holdings to avoid Alpaca rejection
1400+ # (position reversals require closing first, then opening opposite)
1401+ if side == "sell" and current_qty > 0 :
1402+ qty = min (qty , int (abs (current_qty )))
1403+ elif side == "buy" and current_qty < 0 :
1404+ qty = min (qty , int (abs (current_qty )))
1405+
1406+ if qty < 1 :
1407+ continue
1408+
12651409 # Circuit breaker re-check before each trade
12661410 if circuit_breaker is not None :
12671411 try :
0 commit comments