# backend/app/data_manager.py import yfinance as yf import pandas as pd import numpy as np import datetime from app.data_cache import get_cached, save_cache # 默认时间间隔对应的回测历史区间 INTERVAL_TO_PERIOD = { "1m": "5d", "5m": "1mo", "15m": "1mo", "30m": "1mo", "1h": "3mo", "1d": "1y" } # 缓存公司基本信息,避免频繁网络请求导致缓慢 COMPANY_INFO_CACHE = {} def get_company_info(ticker): """ 获取公司名、板块、行业、市值和业务描述,带有缓存与默认兜底,保证极速响应。 """ ticker = ticker.upper() if ticker in COMPANY_INFO_CACHE: return COMPANY_INFO_CACHE[ticker] # 常用股票静态数据库,优先返回以保证零延迟 static_db = { "TSLA": { "name": "Tesla, Inc.", "sector": "Consumer Cyclical (消费周期性)", "industry": "Auto Manufacturers (汽车制造商)", "market_cap": 820000000000, "description": "Tesla Inc. 是一家设计、开发、制造和销售电动汽车、能源生成和存储系统的美国跨国公司。它是全球最受关注的高波动率日内交易标的。" }, "NVDA": { "name": "NVIDIA Corporation", "sector": "Technology (科技)", "industry": "Semiconductors (半导体)", "market_cap": 3150000000000, "description": "NVIDIA Corporation 是一家设计图形处理器(GPU)的半导体跨国科技公司,在人工智能芯片、数据中心和高性能计算领域处于绝对垄断地位。" }, "AAPL": { "name": "Apple Inc.", "sector": "Technology (科技)", "industry": "Consumer Electronics (消费电子)", "market_cap": 3320000000000, "description": "Apple Inc. 是全球最具价值的电子科技公司,主营 iPhone、Mac、iPad 等消费终端设备以及各种云端订阅软件服务,现金流充裕,波动相对稳健。" }, "MSFT": { "name": "Microsoft Corporation", "sector": "Technology (科技)", "industry": "Software—Infrastructure (基础软件)", "market_cap": 3250000000000, "description": "Microsoft Corporation 是全球软件与云服务的龙头企业。旗下拥有 Windows 系统、Azure 云平台、Office 软件,并通过 OpenAI 领跑生成式 AI 时代。" }, "AMD": { "name": "Advanced Micro Devices, Inc.", "sector": "Technology (科技)", "industry": "Semiconductors (半导体)", "market_cap": 260000000000, "description": "Advanced Micro Devices, Inc. 是一家全球半导体公司,主营微处理器(CPU)、显卡(GPU)以及游戏主机定制芯片,与英特尔和英伟达呈竞争关系。" } } if ticker in static_db: COMPANY_INFO_CACHE[ticker] = static_db[ticker] return static_db[ticker] try: stock = yf.Ticker(ticker) info = stock.info name = info.get("longName", info.get("shortName", ticker)) sector = info.get("sector", "General Sector") industry = info.get("industry", "General Industry") market_cap = info.get("marketCap", 0) description = info.get("longBusinessSummary", "No details available.") data = { "name": name, "sector": sector, "industry": industry, "market_cap": market_cap, "description": description } COMPANY_INFO_CACHE[ticker] = data return data except Exception as e: # 如果 yfinance 接口请求出错/被限制,使用兜底值 fallback = { "name": f"{ticker} Corporation", "sector": "General Sector (常规板块)", "industry": "General Industry (常规行业)", "market_cap": 0, "description": f"未能获取到 {ticker} 的网络实时介绍,已自动生成默认档案。该标的目前可参与量化行情回测。" } COMPANY_INFO_CACHE[ticker] = fallback return fallback def calculate_rsi(series, period=14): """ 计算 RSI (相对强弱指标) """ delta = series.diff() gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() rs = gain / loss rsi = 100 - (100 / (1 + rs)) return rsi def calculate_atr(df, period=14): """ 计算 ATR (真实波幅) """ high_low = df['High'] - df['Low'] high_close = np.abs(df['High'] - df['Close'].shift()) low_close = np.abs(df['Low'] - df['Close'].shift()) ranges = pd.concat([high_low, high_close, low_close], axis=1) true_range = np.max(ranges, axis=1) return true_range.rolling(window=period).mean() def calculate_adx(df, period=14): """ 计算 ADX (平均趋向指数),衡量趋势强度 """ df = df.copy() high = df['High'] low = df['Low'] close = df['Close'] # True Range tr1 = high - low tr2 = (high - close.shift(1)).abs() tr3 = (low - close.shift(1)).abs() tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) # Directional Movement up_move = high.diff(1) down_move = -low.diff(1) plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0) minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0) # Smoothed Wilder's MA tr_smooth = pd.Series(tr).ewm(alpha=1/period, adjust=False).mean() plus_dm_smooth = pd.Series(plus_dm, index=df.index).ewm(alpha=1/period, adjust=False).mean() minus_dm_smooth = pd.Series(minus_dm, index=df.index).ewm(alpha=1/period, adjust=False).mean() plus_di = 100 * (plus_dm_smooth / np.maximum(tr_smooth, 1e-8)) minus_di = 100 * (minus_dm_smooth / np.maximum(tr_smooth, 1e-8)) dx = 100 * (plus_di - minus_di).abs() / np.maximum(plus_di + minus_di, 1e-8) adx = dx.ewm(alpha=1/period, adjust=False).mean() return plus_di, minus_di, adx def get_yesterday_levels(ticker): """ 获取昨日的最高价(PDH)、最低价(PDL)、收盘价(PDC) 使用日线级别数据,确保绝对准确 """ try: stock = yf.Ticker(ticker) df_daily = stock.history(period="5d") if len(df_daily) >= 2: yesterday = df_daily.iloc[-2] # -1 是今天,-2 是昨天 return { "PDH": float(yesterday['High']), "PDL": float(yesterday['Low']), "PDC": float(yesterday['Close']) } except Exception as e: print(f"获取昨日关键位置失败 ({ticker}): {str(e)}") return {"PDH": 0.0, "PDL": 0.0, "PDC": 0.0} def compute_candle_features(df): """ 计算K线形态特征向量:实体比例、上影线比例、下影线比例、跳空比例 """ df = df.copy() diff = df['High'] - df['Low'] denom = np.where(diff == 0, 1e-8, diff) df['body_ratio'] = np.abs(df['Close'] - df['Open']) / denom df['upper_shadow_ratio'] = (df['High'] - np.maximum(df['Close'], df['Open'])) / denom df['lower_shadow_ratio'] = (np.minimum(df['Close'], df['Open']) - df['Low']) / denom df['gap_flag'] = (df['Open'] - df['Close'].shift(1)) / (df['Close'].shift(1) + 1e-8) df['body_ratio'] = df['body_ratio'].fillna(0.0) df['upper_shadow_ratio'] = df['upper_shadow_ratio'].fillna(0.0) df['lower_shadow_ratio'] = df['lower_shadow_ratio'].fillna(0.0) df['gap_flag'] = df['gap_flag'].fillna(0.0) return df def fetch_and_prepare_data(ticker, period=None, interval="1m"): """ 获取股票行情并计算量化指标,支持多时间周期。 """ ticker = ticker.upper() if period is None: period = INTERVAL_TO_PERIOD.get(interval, "5d") # 优先尝试从本地缓存读取 cached_df = get_cached(ticker, period, interval) if cached_df is not None: return cached_df # 抓取包含盘前盘后的 K线数据 stock = yf.Ticker(ticker) # 只有分钟级别 (1m, 5m, 15m, 30m, 1h) 支持盘前盘后 prepost is_intraday = interval in ["1m", "5m", "15m", "30m", "1h"] df = stock.history(period=period, interval=interval, prepost=is_intraday) if df.empty: raise ValueError(f"未能获取到 {ticker} 的 {interval} 行情数据。") # 确保时间戳已转换为本地时区 (美东时间) if df.index.tz is None: df = df.tz_localize('UTC').tz_convert('America/New_York') else: df = df.tz_convert('America/New_York') df['Date'] = df.index.date # 计算 VWAP (日内均线按交易日独立累计,如果是日线级别,VWAP 退化为典型价) df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 3 df['TP_Volume'] = df['Typical_Price'] * df['Volume'] if is_intraday: df['Cum_TP_Vol'] = df.groupby('Date')['TP_Volume'].cumsum() df['Cum_Vol'] = df.groupby('Date')['Volume'].cumsum() # 避免除以 0 df['Cum_Vol'] = df['Cum_Vol'].replace(0, 1) df['VWAP'] = df['Cum_TP_Vol'] / df['Cum_Vol'] else: df['VWAP'] = df['Typical_Price'] # 计算 9 / 21 / 50 EMA 和 RSI df['EMA_9'] = df['Close'].ewm(span=9, adjust=False).mean() df['EMA_21'] = df['Close'].ewm(span=21, adjust=False).mean() df['EMA_50'] = df['Close'].ewm(span=50, adjust=False).mean() df['RSI'] = calculate_rsi(df['Close'], period=14) df['ATR'] = calculate_atr(df, period=14) # 补充指标:ADX, MACD, Donchian, OBV, ROC, RVOL df['Plus_DI'], df['Minus_DI'], df['ADX'] = calculate_adx(df, period=14) # MACD ema12 = df['Close'].ewm(span=12, adjust=False).mean() ema26 = df['Close'].ewm(span=26, adjust=False).mean() df['MACD'] = ema12 - ema26 df['MACD_Signal'] = df['MACD'].ewm(span=9, adjust=False).mean() df['MACD_Hist'] = df['MACD'] - df['MACD_Signal'] # 唐奇安通道 (应用 .shift(1) 排除当前K线自身,彻底杜绝未来函数前瞻偏差) df['Donchian_High'] = df['High'].rolling(window=20).max().shift(1) df['Donchian_Low'] = df['Low'].rolling(window=20).min().shift(1) df['Donchian_High_55'] = df['High'].rolling(window=55).max().shift(1) df['Donchian_Low_55'] = df['Low'].rolling(window=55).min().shift(1) # 能量潮指标 (OBV) df['OBV'] = (np.sign(df['Close'].diff()).fillna(0.0) * df['Volume']).cumsum() # 动量 ROC df['ROC'] = df['Close'].pct_change(periods=20) * 100 # 相对成交量 RVOL df['RVOL'] = df['Volume'] / np.maximum(df['Volume'].rolling(window=20).mean(), 1e-8) # 计算 TTM Squeeze 挤压状态 df['BB_Basis'] = df['Close'].rolling(window=20).mean() df['BB_Std'] = df['Close'].rolling(window=20).std() df['BB_Upper'] = df['BB_Basis'] + (2 * df['BB_Std']) df['BB_Lower'] = df['BB_Basis'] - (2 * df['BB_Std']) # 肯特纳通道 (标准:基于 20 EMA 和 2.0 ATR) df['KC_Basis'] = df['Close'].ewm(span=20, adjust=False).mean() df['KC_Upper'] = df['KC_Basis'] + (2.0 * df['ATR']) df['KC_Lower'] = df['KC_Basis'] - (2.0 * df['ATR']) df['Squeeze_On'] = (df['BB_Upper'] < df['KC_Upper']) & (df['BB_Lower'] > df['KC_Lower']) # 状态路由判定 (Regime Classification) df['ATR_Ratio'] = df['ATR'] / df['Close'] high_vol_th = df['ATR_Ratio'].rolling(252, min_periods=20).quantile(0.90).fillna(0.05) regimes = np.array(["range_bound"] * len(df), dtype=object) is_high_vol = df['ATR_Ratio'] > high_vol_th is_trend_up = (df['ADX'] > 20) & (df['EMA_9'] > df['EMA_21']) & (df['EMA_21'] > df['EMA_50']) is_trend_down = (df['ADX'] > 20) & (df['EMA_9'] < df['EMA_21']) & (df['EMA_21'] < df['EMA_50']) # 优先级别:高波动 -> 下跌趋势 -> 上涨趋势 -> 震荡 regimes[is_trend_down] = "trend_down" regimes[is_trend_up] = "trend_up" regimes[is_high_vol] = "high_volatility" df['Regime'] = regimes # 盘前与昨日关键位 if is_intraday: df['Time'] = df.index.time market_open = datetime.time(9, 30) market_close = datetime.time(16, 0) df['Is_Regular_Hours'] = df['Time'].apply(lambda t: market_open <= t <= market_close) df['Is_Pre_Market'] = df['Time'].apply(lambda t: datetime.time(4, 0) <= t < market_open) # 每日计算 PMH / PML 盘前最值 pm_data = df[df['Is_Pre_Market']] if not pm_data.empty: pmh_dict = pm_data.groupby('Date')['High'].max().to_dict() pml_dict = pm_data.groupby('Date')['Low'].min().to_dict() df['PMH'] = df['Date'].map(pmh_dict).fillna(0.0) df['PML'] = df['Date'].map(pml_dict).fillna(0.0) else: df['PMH'] = 0.0 df['PML'] = 0.0 # 每日计算 ORB (开盘前5分钟 9:30-9:35 区间高低点) orb_data = df[(df['Time'] >= datetime.time(9, 30)) & (df['Time'] <= datetime.time(9, 35))] if not orb_data.empty: orb_h_dict = orb_data.groupby('Date')['High'].max().to_dict() orb_l_dict = orb_data.groupby('Date')['Low'].min().to_dict() df['ORB_High'] = df['Date'].map(orb_h_dict).fillna(0.0) df['ORB_Low'] = df['Date'].map(orb_l_dict).fillna(0.0) else: df['ORB_High'] = 0.0 df['ORB_Low'] = 0.0 regular_hours_df = df[df['Is_Regular_Hours']].copy() else: # 日线级别数据全部视为常规时段 regular_hours_df = df.copy() regular_hours_df['Is_Regular_Hours'] = True regular_hours_df['Is_Pre_Market'] = False regular_hours_df['PMH'] = 0.0 regular_hours_df['PML'] = 0.0 regular_hours_df['ORB_High'] = 0.0 regular_hours_df['ORB_Low'] = 0.0 # 获取昨日关键位 yesterday_levels = get_yesterday_levels(ticker) regular_hours_df['PDH'] = yesterday_levels['PDH'] regular_hours_df['PDL'] = yesterday_levels['PDL'] regular_hours_df['PDC'] = yesterday_levels['PDC'] # 清除临时列 regular_hours_df.drop(columns=['Typical_Price', 'TP_Volume', 'Cum_TP_Vol', 'Cum_Vol'], inplace=True, errors='ignore') # 计算 K线形态特征向量 regular_hours_df = compute_candle_features(regular_hours_df) # 填充缺失值,避免初期的 NaN 导致崩溃 regular_hours_df.ffill(inplace=True) regular_hours_df.bfill(inplace=True) # 保存数据到 Parquet 本地缓存 save_cache(ticker, period, interval, regular_hours_df) return regular_hours_df BATCH_QUOTES_CACHE = {} BATCH_QUOTES_TIMESTAMP = None def get_batch_quotes(tickers: list): """ 极速批量获取自选股实时/最新交易日价格、前收价、当日涨跌额、涨跌幅(%)、最高价、最低价与成交量。 带有 15 秒内存缓存与回退兜底,防 Rate Limit 且响应极快。 """ global BATCH_QUOTES_CACHE, BATCH_QUOTES_TIMESTAMP now = datetime.datetime.now() clean_tickers = [str(t).strip().upper() for t in tickers if str(t).strip()] if not clean_tickers: return {} # 15s 内存缓存有效性校验 if BATCH_QUOTES_TIMESTAMP and (now - BATCH_QUOTES_TIMESTAMP).total_seconds() < 15: cached_res = {t: BATCH_QUOTES_CACHE[t] for t in clean_tickers if t in BATCH_QUOTES_CACHE} if len(cached_res) == len(clean_tickers): return cached_res results = {} # 使用 yfinance Fast Info 优先秒拉最新报价(最轻量速度最快) for ticker in clean_tickers: try: stk = yf.Ticker(ticker) fi = stk.fast_info last_price = float(fi.last_price or 0.0) prev_close = float(fi.previous_close or last_price) if last_price > 0: change = last_price - prev_close change_pct = (change / prev_close * 100.0) if prev_close != 0 else 0.0 day_high = float(fi.day_high or last_price) day_low = float(fi.day_low or last_price) volume = int(fi.last_volume or 0) quote_data = { "ticker": ticker, "price": round(last_price, 2), "prev_close": round(prev_close, 2), "change": round(change, 2), "change_percent": round(change_pct, 2), "high": round(day_high, 2), "low": round(day_low, 2), "volume": volume, "timestamp": now.isoformat() } results[ticker] = quote_data BATCH_QUOTES_CACHE[ticker] = quote_data except Exception: pass # 若个别 ticker 在 fast_info 获取失败,尝试使用 history 补救 missing = [t for t in clean_tickers if t not in results] if missing: for ticker in missing: if ticker in BATCH_QUOTES_CACHE: results[ticker] = BATCH_QUOTES_CACHE[ticker] continue try: stk = yf.Ticker(ticker) hist = stk.history(period="5d") if hist is not None and not hist.empty: latest_close = float(hist['Close'].iloc[-1]) prev_close = float(hist['Close'].iloc[-2]) if len(hist) >= 2 else latest_close change = latest_close - prev_close change_pct = (change / prev_close * 100.0) if prev_close != 0 else 0.0 quote_data = { "ticker": ticker, "price": round(latest_close, 2), "prev_close": round(prev_close, 2), "change": round(change, 2), "change_percent": round(change_pct, 2), "high": round(float(hist['High'].iloc[-1]), 2), "low": round(float(hist['Low'].iloc[-1]), 2), "volume": int(hist['Volume'].iloc[-1]), "timestamp": now.isoformat() } results[ticker] = quote_data BATCH_QUOTES_CACHE[ticker] = quote_data except Exception: pass # 再次补漏 fallback 逻辑,保证前端100%有数据返回 for ticker in clean_tickers: if ticker not in results: if ticker in BATCH_QUOTES_CACHE: results[ticker] = BATCH_QUOTES_CACHE[ticker] else: fallback_data = { "ticker": ticker, "price": 100.0, "prev_close": 100.0, "change": 0.0, "change_percent": 0.0, "high": 100.0, "low": 100.0, "volume": 0, "timestamp": now.isoformat() } results[ticker] = fallback_data BATCH_QUOTES_TIMESTAMP = now return results if __name__ == "__main__": print("测试多周期获取数据...") for iv in ["5m", "1d"]: data = fetch_and_prepare_data("AAPL", interval=iv) print(f"周期 {iv}: 获取到 {len(data)} 行数据") info = get_company_info("AAPL") print(f"公司名: {info['name']}, 行业: {info['industry']}")