| |
|
|
| 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: |
| |
| 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'] |
| |
| |
| 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) |
| |
| |
| 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) |
| |
| |
| 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] |
| 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 |
| |
| |
| stock = yf.Ticker(ticker) |
| |
| |
| 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 |
| |
| |
| 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() |
| |
| df['Cum_Vol'] = df['Cum_Vol'].replace(0, 1) |
| df['VWAP'] = df['Cum_TP_Vol'] / df['Cum_Vol'] |
| else: |
| df['VWAP'] = df['Typical_Price'] |
| |
| |
| 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) |
| |
| |
| df['Plus_DI'], df['Minus_DI'], df['ADX'] = calculate_adx(df, period=14) |
| |
| |
| 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'] |
| |
| |
| 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) |
| |
| |
| df['OBV'] = (np.sign(df['Close'].diff()).fillna(0.0) * df['Volume']).cumsum() |
| |
| |
| df['ROC'] = df['Close'].pct_change(periods=20) * 100 |
| |
| |
| df['RVOL'] = df['Volume'] / np.maximum(df['Volume'].rolling(window=20).mean(), 1e-8) |
| |
| |
| 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']) |
| |
| |
| 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']) |
| |
| |
| 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) |
| |
| |
| 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_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') |
| |
| |
| regular_hours_df = compute_candle_features(regular_hours_df) |
| |
| |
| regular_hours_df.ffill(inplace=True) |
| regular_hours_df.bfill(inplace=True) |
| |
| |
| 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 {} |
|
|
| |
| 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 = {} |
| |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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']}") |
|
|