據(jù)分析實(shí)戰(zhàn):從爬蟲采集到回測驗(yàn)證全流程)
簡介面向金融數(shù)據(jù)分析初學(xué)者與有一定基礎(chǔ)的Python開發(fā)者這份資源以股票市場數(shù)據(jù)為實(shí)戰(zhàn)場景系統(tǒng)串聯(lián)起數(shù)據(jù)采集、清洗、分析與建模全流程。內(nèi)容涉及Python爬蟲編寫與反爬應(yīng)對(duì)、Pandas處理缺失值和格式統(tǒng)一、NumPy/SciPy進(jìn)行統(tǒng)計(jì)計(jì)算、Matplotlib/Seaborn繪制K線與箱型圖以及利用Scikit-learn構(gòu)建回歸、決策樹或隨機(jī)森林等預(yù)測模型并延伸至Tushare實(shí)時(shí)數(shù)據(jù)獲取與Flask部署等環(huán)節(jié)。資源包為7z壓縮格式整體僅4.47MB便于下載后按主題查閱。目前已有3654人學(xué)習(xí)適合希望快速上手股票大數(shù)據(jù)分析并用于課程設(shè)計(jì)或個(gè)人量化研究參考的讀者。1. 從數(shù)據(jù)到?jīng)Q策這份股票大數(shù)據(jù)分析Python實(shí)戰(zhàn)包能解決什么用Python分析股票數(shù)據(jù)卡住大多數(shù)人的不是算法而是第一步數(shù)據(jù)從哪來、怎么清洗、怎么才能保證算出來的指標(biāo)沒被復(fù)權(quán)除權(quán)搞亂。我拆過幾個(gè)模擬項(xiàng)目后發(fā)現(xiàn)一份能落地的“股票大數(shù)據(jù)分析Python實(shí)戰(zhàn)包”真正起作用的是其中那條從爬蟲采集、數(shù)據(jù)清洗到策略驗(yàn)證的完整鏈路。這個(gè)資源包的核心就是把“python 爬蟲”和“股票數(shù)據(jù)分析”焊在一起讓你不用從頭去翻接口文檔、不用反復(fù)調(diào)試數(shù)據(jù)缺失的坑跟著代碼走就能把日線數(shù)據(jù)拉下來、算成特征、跑出信號(hào)。適合手里有Python基礎(chǔ)但沒完整做過量化數(shù)據(jù)處理的從業(yè)者也適合想拿真實(shí)行情練手Pandas和爬蟲的同學(xué)。它解決的不是“股票會(huì)不會(huì)漲”這種玄學(xué)問題而是“行情數(shù)據(jù)到手之后怎么處理才能用來做決策”的工程問題——這部分恰恰是很多教程跳過的環(huán)節(jié)。2. 環(huán)境與數(shù)據(jù)獲取先把可復(fù)現(xiàn)的數(shù)據(jù)管道搭起來2.1 建立隔離的Python分析環(huán)境拿到資源包后我習(xí)慣先做一件事絕不直接往全局環(huán)境里裝依賴。原因很簡單股票數(shù)據(jù)分析涉及的庫版本沖突很常見——Pandas 2.x 和 1.x 的 API 差異、NumPy 版本不兼容都可能讓昨天的代碼今天跑出完全不同的結(jié)果。資源包里一般會(huì)帶 requirements.txt但在安裝之前先用虛擬環(huán)境把依賴隔離出來。python -m venv stock_venv source stock_venv/bin/activate # Windows 下為 stock_venv\Scripts\activate pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple邏輯說明venv創(chuàng)建獨(dú)立解釋器環(huán)境source激活后pip安裝的包只屬于該項(xiàng)目。用清華鏡像安裝是為了避免默認(rèn)源在大陸訪問緩慢的問題實(shí)測速度能差好幾倍。參數(shù)說明stock_venv是虛擬環(huán)境目錄名可以按項(xiàng)目改如果資源包沒有requirements.txt手動(dòng)安裝時(shí)建議先把pandas、numpy、requests、matplotlib這四件套裝好其他的按需逐步補(bǔ)。環(huán)境裝好后先跑一下資源包自帶的入口腳本。一般是一個(gè)main.py或run_analysis.py它做的事情是驗(yàn)證數(shù)據(jù)源連通性。這個(gè)步驟很關(guān)鍵——如果數(shù)據(jù)接口的 token 過期或網(wǎng)絡(luò)不通后面所有分析都是空轉(zhuǎn)。我一般會(huì)在這個(gè)階段先確認(rèn) Python 版本股票分析相關(guān)的庫對(duì) 3.8 以下支持并不好建議用 3.9 以上。2.2 用 Pandas 對(duì)接行情接口從 Token 到 DataFrame這個(gè)實(shí)戰(zhàn)包通常以某財(cái)經(jīng)數(shù)據(jù)平臺(tái)的開放接口作為默認(rèn)數(shù)據(jù)源代碼風(fēng)格是requests或官方 SDK 封裝。我先講講接口對(duì)接里的關(guān)鍵token。數(shù)據(jù)接口通過 token 識(shí)別身份如果資源包里帶了 token 變量看下是不是寫死在某配置文件里。我一般習(xí)慣把 token 放到環(huán)境變量里避免代碼提交到公開倉庫時(shí)泄露。import os import pandas as pd from requests import Session TOKEN os.getenv(STOCK_API_TOKEN) sess Session() sess.headers.update({Authorization: fBearer {TOKEN}}) def fetch_daily(code, start_date, end_date): url https://api.example-stock-data.com/daily params { ts_code: code, start_date: start_date, end_date: end_date, fields: trade_date,open,high,low,close,vol } resp sess.get(url, paramsparams, timeout10) resp.raise_for_status() data resp.json() df pd.DataFrame(data[items], columnsdata[fields]) return df邏輯說明這里用Session復(fù)用 TCP 連接避免每次請求都重新握手token 從環(huán)境變量讀取不硬編碼在代碼里。timeout10是請求超時(shí)秒數(shù)數(shù)據(jù)量大或網(wǎng)絡(luò)差時(shí)建議調(diào)到 20。參數(shù)說明ts_code是股票代碼參數(shù)通常格式為000001.SZ或600000.SHfields字段列表按需取別一股腦拉全字段——接口響應(yīng)體越大解析越慢token 額度也浪費(fèi)得越快。拿到DataFrame后先做兩件小事看一眼dtypes確認(rèn)trade_date被解析成了datetime64而不是字符串再看一眼isna().sum()確認(rèn)沒有整列缺失。這兩步能省后面大量排錯(cuò)時(shí)間。原始數(shù)據(jù)格式通常是{items: [], fields: []}這種結(jié)構(gòu)資源包里一般已經(jīng)封裝好了解析函數(shù)但你要知道它內(nèi)部做了什么——我見過不少人拿到的列是字符串時(shí)間排序按字典序排結(jié)果圖全亂了。2.3 增量更新與歷史全量數(shù)據(jù)完整性的保障股票數(shù)據(jù)接口最麻煩的問題不是拉一次而是“怎么持續(xù)更新”。比如一個(gè)模擬項(xiàng)目的目標(biāo)是分析最近三年的日線數(shù)據(jù)首次全量拉取可能要跑很久之后每天只需補(bǔ)充最新一天的數(shù)據(jù)。def update_daily(code, csv_pathdaily.csv): try: old pd.read_csv(csv_path, parse_dates[trade_date]) except FileNotFoundError: old pd.DataFrame() latest old[trade_date].max() if len(old) else None end pd.Timestamp.today().strftime(%Y%m%d) start (latest pd.Timedelta(days1)).strftime(%Y%m%d) if latest else 20200101 new fetch_daily(code, start, end) merged pd.concat([old, new], ignore_indexTrue).drop_duplicates(subset[trade_date]).sort_values(trade_date) merged.to_csv(csv_path, indexFalse) return merged邏輯說明這一段先讀本地已有 CSV取最大日期作為增量更新的起點(diǎn)避免重復(fù)拉取。drop_duplicates按trade_date去重防止某次請求超時(shí)重試后產(chǎn)生重復(fù)行。參數(shù)說明csv_path是本地緩存文件路徑如果你改用數(shù)據(jù)庫比如 SQLite邏輯完全一樣只是讀寫那兩行換成語句。start用字符串拼接是因?yàn)榻涌谕ǔR骙YYYMMDD格式Pandas 的Timestamp不能直接傳。增量更新的好處不止是省流量——更重要的是保持本地?cái)?shù)據(jù)和接口狀態(tài)一致。如果某天接口的數(shù)據(jù)源本身出了問題比如某只股票停牌當(dāng)天沒返回記錄增量邏輯不會(huì)主動(dòng)發(fā)現(xiàn)。所以我一般每周做一次全量校驗(yàn)把本地最后一行的trade_date和接口最新的trade_date對(duì)比差超過一個(gè)交易日就觸發(fā)重新拉取。資源包里這個(gè)邏輯可能寫得比較簡單建議拿到后按實(shí)際需求補(bǔ)強(qiáng)。3. 爬蟲實(shí)戰(zhàn)把請求頻率、斷點(diǎn)續(xù)爬和字段抽取做成工程3.1 為什么直接爬網(wǎng)頁通常不是好選擇“python 爬蟲”這個(gè)詞容易讓人想到爬網(wǎng)頁 HTML 然后解析表格但在股票數(shù)據(jù)場景里直接爬網(wǎng)頁是最差的選擇原因有兩點(diǎn)。第一財(cái)經(jīng)網(wǎng)站的頁面結(jié)構(gòu)變動(dòng)頻繁今天能解析的表格明天可能就換了 CSS 類名維護(hù)成本極高。第二大量股票數(shù)據(jù)頁面是通過 JavaScript 異步加載的直接requests.get拿到的 HTML 里根本沒有數(shù)據(jù)要么用 Selenium 這種重型工具要么破解 XHR 接口地址兩個(gè)方向都是血淚教訓(xùn)。但這個(gè)資源包里一般不會(huì)只用官方接口因?yàn)橛行└哳l因子和盤中數(shù)據(jù)接口不給普通用戶開放。這時(shí)候就要寫爬蟲去抓公開的網(wǎng)頁接口——注意是抓 JSON 接口不是抓 HTML。常見做法是用開發(fā)者工具打開任意財(cái)經(jīng)網(wǎng)站的數(shù)據(jù)頁在“網(wǎng)絡(luò)”標(biāo)簽里過濾XHR請求找到真正返回?cái)?shù)據(jù)的那條鏈接。它通常是個(gè)帶一堆參數(shù)的 GET 請求返回的是 JSON解析起來比 HTML 安穩(wěn)得多。3.2 用 Requests Session 封裝一個(gè)行情讀取器抓 JSON 接口的爬蟲核心就三件事帶好 Headers、控制頻率、做好重試。我自己封裝的時(shí)候是這么寫的import time import random from requests import Session class MarketReader: def __init__(self, base_url, min_interval1.0): self.sess Session() self.sess.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://example-finance-site.com, Accept: application/json, text/plain, */* }) self.base_url base_url self.min_interval min_interval self._last_req 0 def get_json(self, path, params): now time.time() wait self._last_req self.min_interval - now if wait 0: time.sleep(wait random.uniform(0.1, 0.5)) resp self.sess.get(f{self.base_url}{path}, paramsparams, timeout15) self._last_req time.time() resp.raise_for_status() return resp.json() def fetch_kline(self, symbol, perioddaily): data self.get_json(/api/kline, { symbol: symbol, period: period, limit: 1000 }) records data.get(data, {}).get(items, []) return [{date: r[0], close: r[2], volume: r[5]} for r in records]邏輯說明min_interval用來控制兩次請求的最小間隔默認(rèn) 1 秒對(duì)多數(shù)公開接口都?jí)蛴谩andom.uniform加一個(gè)隨機(jī)抖動(dòng)避免請求節(jié)奏太規(guī)律被限流。_last_req記錄上一次請求時(shí)間用于計(jì)算是否需要等待。參數(shù)說明這里的period支持daily和weekly接口不同參數(shù)名可能不同具體要看抓到的 XHR 請求定義。很多翻車的爬蟲都是因?yàn)闆]帶Referer或者UA太假被拒絕。這個(gè)封裝里 UA 用的是 Chrome 的標(biāo)準(zhǔn)格式Referer 指向頁面地址——這兩個(gè)缺失或不對(duì)接口大概率返回 403。帶上之后通常能順利拿到 JSON。實(shí)際運(yùn)行時(shí)建議把timeout設(shè)大一點(diǎn)有些接口在拉長周期數(shù)據(jù)時(shí)服務(wù)端要算很久15 秒比較穩(wěn)妥。3.3 多線程拉取與斷點(diǎn)續(xù)爬應(yīng)對(duì)大范圍歷史數(shù)據(jù)單線程拉全量歷史數(shù)據(jù)很慢。比如你要拉滬深兩市共 5000 多只股票的周線數(shù)據(jù)單線程按 1 秒 1 次算要跑一個(gè)多小時(shí)而且中間斷一次就前功盡棄。資源包里一般會(huì)帶多線程方案但我要提醒一句多線程不是越多越好接口限流是按 token 或 IP 算的開 20 個(gè)線程如果被限流反而比單線程更慢。我一般把線程數(shù)壓在 5 到 8 個(gè)同時(shí)加一個(gè)進(jìn)度記錄文件做到“斷點(diǎn)續(xù)爬”。import threading import os import json from queue import Queue def worker(q, results, lock): while not q.empty(): try: symbol q.get(timeout1) data reader.fetch_kline(symbol, perioddaily) with lock: results[symbol] data done_list.append(symbol) save_progress(done_list) except Exception as e: print(ffailed: {symbol}: {e}) finally: q.task_done() def save_progress(done_list): with open(progress.json, w) as f: json.dump(done_list, f) q Queue() all_symbols load_symbols(symbols.txt) # 每行一個(gè)股票代碼 for s in all_symbols: q.put(s) done_list load_progress() if os.path.exists(progress.json) else [] results {} lock threading.Lock() for _ in range(6): t threading.Thread(targetworker, args(q, results, lock)) t.start() q.join()邏輯說明Queue是線程安全的多個(gè) worker 從隊(duì)列里取任務(wù)不會(huì)重復(fù)處理。lock保護(hù)results字典和progress.json的寫入避免多線程同時(shí)寫文件導(dǎo)致 JSON 損壞。save_progress每完成一個(gè)股票就記錄一次程序崩了之后重新運(yùn)行時(shí)從done_list里剔除已完成項(xiàng)即可。參數(shù)說明symbols.txt是股票代碼清單每行一個(gè)如果資源包自帶清單更好沒有的話可以用akshare或接口的stock_basic接口拉取。這里有一個(gè)重要的點(diǎn)斷點(diǎn)續(xù)爬的前提是“寫盤夠快且夠頻繁”。save_progress每次完整重寫 JSON 文件在 5000 只股票的場景下文件不超過 100KB開銷可以忽略。如果數(shù)據(jù)量更大建議改成 SQLite 記錄進(jìn)度。多線程踩坑的重點(diǎn)是千萬別讓兩個(gè)線程同時(shí)寫同一個(gè)文件lock就是干這個(gè)用的。另外q.empty()判斷在并發(fā)下不完全可靠但任務(wù)分發(fā)完成后消費(fèi)者不會(huì)再往里放任務(wù)所以這里的場景下夠用。4. 因子計(jì)算與預(yù)處理讓原始行情變成可分析的特征4.1 復(fù)權(quán)因子處理避免算出假信號(hào)原始行情里的close是未復(fù)權(quán)價(jià)遇到分紅送股價(jià)格會(huì)出現(xiàn)斷崖式跳空。如果沒有做復(fù)權(quán)處理任何基于價(jià)格的指標(biāo)都會(huì)失真。最常見的坑是某只股票 10 送 10除權(quán)日價(jià)格從 20 元變成 10 元均線系統(tǒng)會(huì)“檢測”出一個(gè)巨大下跌信號(hào)——但實(shí)際上這只是權(quán)益變動(dòng)不是市場下跌。資源包里的數(shù)據(jù)如果來自統(tǒng)一接口一般會(huì)提供adj_factor字段或qfq前復(fù)權(quán)選項(xiàng)。前復(fù)權(quán)會(huì)把歷史價(jià)格按最新股本調(diào)整保證歷史走勢連續(xù)。處理邏輯是把原始價(jià)格乘上復(fù)權(quán)因子再除以最新因子值或者直接用接口的adj參數(shù)。我拿到數(shù)據(jù)后會(huì)先做一個(gè)“除權(quán)跳空檢測”def detect_gap(df, threshold0.2): df df.sort_values(trade_date) pct df[close].pct_change() gap_days df[pct.abs() threshold].index.tolist() return gap_days邏輯說明pct_change計(jì)算相鄰交易日的漲跌幅閾值為 20%。如果某天漲跌幅絕對(duì)值超過 20%注冊制股票除外幾乎可以斷定是未復(fù)權(quán)數(shù)據(jù)里的除權(quán)跳空。注意這個(gè)檢測只用于輔助判斷數(shù)據(jù)質(zhì)量不能替代正式的復(fù)權(quán)因子處理。參數(shù)說明threshold按市場調(diào)整A 股普通股票設(shè) 0.2 夠用科創(chuàng)板/創(chuàng)業(yè)板個(gè)股日內(nèi)波動(dòng)大可以調(diào)到 0.3但再高就可能漏檢。4.2 高頻與低頻數(shù)據(jù)的重采樣對(duì)齊股票分析中經(jīng)常遇到不同頻率的數(shù)據(jù)要合并——比如把日線數(shù)據(jù)和分鐘線數(shù)據(jù)對(duì)齊到周線。Pandas 的resample能搞定但有個(gè)隱含陷阱交易日的“日”和自然日的“日”不是一回事。如果用resample(W)按自然周聚合法定節(jié)假日會(huì)導(dǎo)致某些周只有 2 個(gè)交易日周均線跟交易所口徑對(duì)不上。正確做法是先用交易日歷生成有效交易日的DatetimeIndex再對(duì)齊import pandas as pd def resample_trading(df, ruleW-FRI): df df.set_index(trade_date) df df.resample(rule).agg({ open: first, high: max, low: min, close: last, vol: sum }).dropna() return df邏輯說明W-FRI表示按周聚合錨定在周五一周內(nèi)的交易日數(shù)據(jù)合并到周五這個(gè)索引上。open取周內(nèi)第一個(gè)交易日的開盤價(jià)close取最后一個(gè)的收盤價(jià)high取周內(nèi)最高low取最低vol求和。dropna去掉沒有交易數(shù)據(jù)的自然周。參數(shù)說明如果數(shù)據(jù)源沒有交易日歷字段可以用exchange_calendars庫生成或者直接用df.index里去重后的日期做后續(xù)匹配。這里容易翻車的點(diǎn)是resample默認(rèn)是按日歷日頻率遇到周末和節(jié)假日會(huì)創(chuàng)建空數(shù)據(jù)行dropna才能清掉。但注意agg字典里的first和last在空數(shù)據(jù)上會(huì)返回NaN不要用fillna去填否則會(huì)把不存在的交易周也算進(jìn)去。4.3 構(gòu)造常用技術(shù)指標(biāo)的 Pandas 實(shí)現(xiàn)資源包最實(shí)用的一部分就是指標(biāo)計(jì)算代碼。均線、RSI、MACD 這類常用指標(biāo)的 Pandas 實(shí)現(xiàn)并不復(fù)雜難在邊界條件。我以 RSI 為例——它經(jīng)常被當(dāng)作買賣信號(hào)但不同平臺(tái)的計(jì)算差異導(dǎo)致結(jié)果不同def compute_rsi(close, period14): delta close.diff() gain delta.clip(lower0) loss -delta.clip(upper0) avg_gain gain.rolling(windowperiod, min_periodsperiod).mean() avg_loss loss.rolling(windowperiod, min_periodsperiod).mean() rs avg_gain / avg_loss rsi 100 - (100 / (1 rs)) return rsi邏輯說明diff計(jì)算相鄰收盤價(jià)差clip(lower0)把負(fù)值歸零得到上漲幅度clip(upper0)取負(fù)并取絕對(duì)值得到下跌幅度。rolling(windowperiod, min_periodsperiod)要求窗口內(nèi)至少 14 個(gè)有效值前 13 個(gè)交易日輸出NaN。參數(shù)說明period是回看天數(shù)默認(rèn) 14 是經(jīng)典參數(shù)有的平臺(tái)用 SMMA平滑移動(dòng)平均而非簡單平均算avg_gain這是不同平臺(tái) RSI 數(shù)值不一致的主要原因。如果做跨平臺(tái)策略校驗(yàn)務(wù)必確認(rèn)對(duì)方用的是哪種平滑方式。這里有一個(gè)很玄學(xué)的點(diǎn)RSI 的公式里avg_loss如果為 0連續(xù)上漲rs會(huì)變成無窮大rsi會(huì)返回NaN。資源包里如果沒處理這個(gè)邊界回測時(shí)就會(huì)出現(xiàn)信號(hào)丟失。修正方法很簡單rs計(jì)算前先把a(bǔ)vg_loss為 0 的行替換為極大值。我經(jīng)手過的模擬項(xiàng)目X里這個(gè)坑導(dǎo)致周末回測結(jié)果和實(shí)盤完全對(duì)不上查了半天才發(fā)現(xiàn)是 RSI 計(jì)算在極端行情下靜默失敗。拿到資源包后建議先跑一遍內(nèi)置的自檢函數(shù)確認(rèn)指標(biāo)邊界條件都處理過。5. 常見問題與排查這五類錯(cuò)我?guī)缀趺看味紩?huì)踩一遍5.1 現(xiàn)象回測結(jié)果里出現(xiàn)巨額收益但實(shí)際走勢沒有原因數(shù)據(jù)里包含了未復(fù)權(quán)價(jià)格或者復(fù)權(quán)因子計(jì)算錯(cuò)誤。某只股票經(jīng)歷多次分紅后未復(fù)權(quán)價(jià)格長期看起來“陰跌不止”但策略按價(jià)格差算收益時(shí)會(huì)出現(xiàn)一個(gè)假的大額盈利。解決拿到數(shù)據(jù)后先做除權(quán)跳空檢測見 4.1再對(duì)應(yīng)復(fù)權(quán)因子檢查。資源包如果帶了復(fù)權(quán)接口直接調(diào)用接口的adj參數(shù)拉前復(fù)權(quán)數(shù)據(jù)不要自己在本地做除法——除數(shù)用錯(cuò)一步全錯(cuò)。5.2 現(xiàn)象K 線圖的日期明明是周一卻顯示的是周五的數(shù)據(jù)原因數(shù)據(jù)的時(shí)區(qū)是 UTC世界標(biāo)準(zhǔn)時(shí)間或者美東時(shí)間而本地日期是北京時(shí)間。某些國際數(shù)據(jù)接口返回的trade_date帶時(shí)區(qū)偏移to_pandas后沒做tz_convert導(dǎo)致日期整體偏移。特別是凌晨時(shí)段的數(shù)據(jù)UTC 日期比北京時(shí)間早 8 小時(shí)周末和交易日的對(duì)齊全亂。解決拉數(shù)據(jù)后統(tǒng)一執(zhí)行df[trade_date] df[trade_date].dt.tz_convert(Asia/Shanghai)或者在讀取時(shí)指定parse_dates并傳入utcTrue。如果接口返回的是無時(shí)區(qū)字符串但實(shí)際代表北京時(shí)間直接解析即可千萬別畫蛇添足。5.3 現(xiàn)象爬蟲跑到一半開始大量返回 403 或 HTTP 429原因請求頻率太快或者 UA/Referer 被目標(biāo)服務(wù)器識(shí)別為“非正常訪客”。很多公開接口的限流策略是按 IPUA 組合計(jì)算的即使你單線程運(yùn)行如果請求間隔小于 0.5 秒也可能觸發(fā)封禁。解決把min_interval調(diào)到 1 秒以上UA 換成真實(shí)的 Chrome UA 字符串Referer 設(shè)為頁面地址。如果已經(jīng)被封等 10 分鐘到半小時(shí)再試不要立刻重啟爬蟲——那只會(huì)讓封禁時(shí)間延長。我在模擬項(xiàng)目X中就是沒注意_last_req的初始值為 0導(dǎo)致第一次請求前沒有等待幾百次請求后直接被斷連。5.4 現(xiàn)象回測優(yōu)化的策略在實(shí)盤里完全失效原因前視偏差。最常見的是用當(dāng)天的收盤價(jià)算出信號(hào)然后當(dāng)天就成交——但真實(shí)情況下你得等收盤后才能知道當(dāng)天的收盤價(jià)交易指令最早也只能在次日開盤執(zhí)行。數(shù)據(jù)在時(shí)間軸上“偷看”了未來信息回測自然好看實(shí)盤自然翻車。解決信號(hào)生成后必須shift(1)讓信號(hào)延遲一天生效或者嚴(yán)格按“次日開盤價(jià)”成交。資源包如果自帶回測引擎先確認(rèn)它的成交價(jià)格是不是用了close.shift(1)。這個(gè)是最隱蔽的坑很多初學(xué)者完全意識(shí)不到。5.5 現(xiàn)象兩次運(yùn)行同一段代碼結(jié)果出現(xiàn)細(xì)微差異原因浮點(diǎn)精度。Pandas 的rolling.mean()用累加方式計(jì)算大數(shù)據(jù)量下會(huì)出現(xiàn)微小的精度差異另外resample聚合時(shí)多線程或并行模式下可能有誤差。解決做關(guān)鍵價(jià)格比較時(shí)用round(..., 6)或者用np.isclose做比較判斷。數(shù)據(jù)量在幾萬行時(shí)這個(gè)誤差可能只有 1e-10但累積到信號(hào)判斷的閾值邊緣就可能造成一次交易的開與不開。不要相信“浮點(diǎn)誤差是小事”在策略回測里這就是大事。6. 從靜態(tài)分析到簡單回測驗(yàn)證你的股票策略是否真的可靠6.1 用向量化回測快速驗(yàn)證 MA 均線策略資源包加載了干凈數(shù)據(jù)、算好了指標(biāo)之后最值得做的一件事就是跑一個(gè)最小可用的回測驗(yàn)證數(shù)據(jù)的時(shí)序邏輯是否自洽。我常用雙均線策略做“冒煙測試”——如果連這個(gè)策略都跑不出合理結(jié)果那數(shù)據(jù)或指標(biāo)一定有問題。def backtest_ma(df, short5, long20): df df.copy().sort_values(trade_date) df[ma_short] df[close].rolling(short).mean() df[ma_long] df[close].rolling(long).mean() df[signal] (df[ma_short] df[ma_long]).astype(int) df[position] df[signal].shift(1) # 次日開盤執(zhí)行 df[ret] df[close].pct_change() df[strategy_ret] df[position] * df[ret] df[cum_ret] (1 df[strategy_ret]).cumprod() - 1 return df邏輯說明signal是當(dāng)日收盤后的判斷position用shift(1)延遲到次日生效避免前視偏差。strategy_ret是持倉收益乘以當(dāng)日漲跌幅cumprod累積得到凈值曲線。參數(shù)說明short和long分別是短、長均線窗口5和20是經(jīng)典參數(shù)。注意這里沒有考慮手續(xù)費(fèi)和漲跌停限制結(jié)果偏樂觀但作為冒煙測試足夠。跑完看兩件事第一回測期末的累計(jì)凈值是不是在合理區(qū)間——如果翻了 100 倍得懷疑數(shù)據(jù)或邏輯第二position序列有沒有頻繁切換——一段趨勢行情里切了十幾次多半是數(shù)據(jù)有重復(fù)行或時(shí)間戳混亂。這兩點(diǎn)過了才放心做更復(fù)雜的策略驗(yàn)證。6.2 用回測結(jié)果反推數(shù)據(jù)質(zhì)量回測里經(jīng)常會(huì)發(fā)現(xiàn)隱藏的數(shù)據(jù)質(zhì)量問題。比如某天close是 0pct_change會(huì)變成無窮大導(dǎo)致整條累積凈值線直接斷裂比如停牌日的價(jià)格沒有NaN而是重復(fù)了上一交易日導(dǎo)致rolling(5)計(jì)算出看似正常的均線但實(shí)際少了一天。我一般會(huì)在回測前加一個(gè)數(shù)據(jù)質(zhì)量斷言assert df[close].isna().sum() 0, 存在空收盤價(jià) assert (df[close] 0).sum() 0, 存在非正收盤價(jià) assert df[vol].dtype in (int64, float64), 成交量類型異常這三條斷言能把大部分臟數(shù)據(jù)擋在回測之前。資源包如果自帶了內(nèi)置檢查雙擊運(yùn)行一下即可如果沒有建議自己加上成本極低收益極高。做完這個(gè)再跑策略心里踏實(shí)得多。6.3 可視化呈現(xiàn)把信號(hào)與凈值曲線畫出來回測結(jié)果最終要看圖才能定位問題。我習(xí)慣把收盤價(jià)、均線、買賣信號(hào)點(diǎn)和凈值曲線畫在一張圖上一眼就能看出信號(hào)是否出現(xiàn)在合理的位置——如果買點(diǎn)全出現(xiàn)在大漲之后而不是啟動(dòng)之初說明信號(hào)邏輯有未來函數(shù)或滯后問題。import matplotlib.pyplot as plt def plot_strategy(df): fig, ax plt.subplots(2, 1, figsize(12, 8), sharexTrue) ax[0].plot(df[trade_date], df[close], labelClose, lw1) ax[0].plot(df[trade_date], df[ma_short], labelfMA{5}, lw1) ax[0].plot(df[trade_date], df[ma_long], labelfMA{20}, lw1) buy_idx df[df[signal].diff() 1].index sell_idx df[df[signal].diff() -1].index ax[0].scatter(df.loc[buy_idx, trade_date], df.loc[buy_idx, close], marker^, colorr, s50) ax[0].scatter(df.loc[sell_idx, trade_date], df.loc[sell_idx, close], markerv, colorg, s50) ax[0].legend() ax[1].plot(df[trade_date], df[cum_ret], labelStrategy Return, lw1) ax[1].legend() plt.tight_layout() plt.show()邏輯說明signal.diff() 1表示信號(hào)從 0 變成 1 的轉(zhuǎn)折點(diǎn)也就是買入信號(hào)diff() -1是賣出信號(hào)。從信號(hào)點(diǎn)和價(jià)格的位置關(guān)系能直觀判斷是否有滯后或超前。參數(shù)說明figsize控制圖幅buy_idx和sell_idx分別存儲(chǔ)買賣點(diǎn)索引。如果信號(hào)點(diǎn)全部出現(xiàn)在均線交叉后的第 N 根 K 線說明shift層級(jí)可能不對(duì)?;販y不是終點(diǎn)而是數(shù)據(jù)質(zhì)量的試金石。從那以后我每次拿到新的數(shù)據(jù)資源都強(qiáng)制走一遍“跳空檢測 → 時(shí)區(qū)對(duì)齊 → 冒煙策略 → 畫圖目測”這個(gè)流程不跑完這一步就不碰后續(xù)的任何高級(jí)模型。這套流程幫我擋下了至少三次因?yàn)閿?shù)據(jù)沒復(fù)權(quán)導(dǎo)致的假信號(hào)也讓我少走了很多彎路。希望這份實(shí)戰(zhàn)筆記里提到的坑和參數(shù)能讓你在跑自己的股票數(shù)據(jù)分析項(xiàng)目時(shí)更順一點(diǎn)。本文還有配套的精品資源點(diǎn)擊獲取