
做跨境量化或者单纯研究日本股市最头疼的往往不是策略本身而是“数据从哪里来”。东京证券交易所官网确实可以找到日次行情的Excel表格但真要手动把十年历史数据一份一份保存下来效率低到让人想放弃。我自己从踩坑到跑通这套流程前后折腾了两天最后沉淀成一条完整的实践路径用公开API拉取东证指数和个股历史行情再用Python做收益、波动率、相关性和简单策略回测。这篇文章就是这条路线的全程记录适合正在做量化入门、跨境投资研究或者单纯对日本市场感兴趣的朋友照着跑完一遍你至少能自己拿到日经225、东证主板个股的历史数据并输出几张能拿去汇报的分析图表。1. 先搞清楚东证历史数据有什么渠道每个渠道什么脾气1.1 三类公开数据源免费行情源、券商行情源、官方数据选择数据源之前先要知道东京证券交易所的行情数据到底通过什么方式对外提供。市面上最常见的其实是三类数据源获取方式典型符号示例优点缺点或坑yfinancePython直接调用无需注册^N225日经225、7203.T丰田覆盖全、历史长、社区资料多非官方接口接口变更时容易失效并发下载容易触发限流Stooqpandas_datareader或CSV下载^NKX日经225、7203.jp轻量、免费、格式规整日本个股覆盖不如yfinance全部分数据更新可能有延后Kabu Station API日本券商公开API需申请密钥kabu.STATION品牌下的各接口实时性强、适合分钟级数据需要开通日本券商账户认证和额度限制较多交易所及财经网站页面手工下载Excel/CSV无标准自动化结构权威、准确页面结构经常调整自动化成本高爬取合规风险也大我日常用得最多的是前两个yfinance负责主数据源Stooq负责交叉校验。尤其是拉指数和主板个股yfinance虽然是非官方接口但胜在生态成熟出问题后网上一搜基本都有解决方案。Kabu Station那类券商API适合行情精度到分钟级的实盘研究普通日线历史分析用不上等你要做盘中策略再考虑也不迟。为什么不建议直接去爬东京证券交易所官网TSE官网上虽然挂着“日次行情”的Excel下载入口但文件结构每隔一段时间就会变一次。今天解析脚本写好了明天列头多了个字段脚本直接崩掉。API的标准化响应格式虽然也有版本迭代但比HTML表格稳定得多最重要的是处理逻辑能复用。1.2 用API做分析之前必须想明白日股这三个特殊性拿到数据就开算先别急。日本股票市场有几个特殊性会直接影响分析结果不处理干净后面图表全是错的。第一是除权除息带来的价格跳空。日本上市公司习惯在每年3月末集中除权有的公司还会拆股。如果你用不复权价格直接算收益率除息日当天股价凭空下跌一截程序会把分红当成亏损。这就好比你体检时护士突然换了一台没校准的体重秤体重数字变了但到底是真瘦了还是秤的问题完全分不清。所以后面所有策略回测、波动率计算我都会用复权价格。第二是时区与交易时间。东京证券交易所的交易时段是东京时间09:00到15:30与UTC有9小时时差。拉日线数据时很多数据源默认返回UTC时间戳如果不做转换就按日对齐边界日期的数据容易错位。日本没有夏令时所以UTC9是固定不变的处理起来反而比美股简单。第三是交易日历跟欧美不一样。日本有自己的假期体系比如春分日、秋分日、黄金周、山之日等等。如果你直接用美股交易日历去对齐日本数据得到的结果会有系统性偏差。最简单的确认方式不是自己去维护一套日历而是看数据源返回的索引里实际有哪些日期然后用已有日期为准做后续分析。2. 环境准备与API连接把拉数管道一次搭好2.1 安装依赖与目录约定我推荐直接建一个独立项目目录不要什么东西都往临时文件里堆。目录结构可以参考这样tse_research/ ├── data/ # 存放原始csv ├── notebooks/ # 实验性质的分析脚本 └── output/ # 图表与结果报告依赖安装很简单一条命令搞定pip install yfinance pandas numpy matplotlib seaborn pandas-datareader这里pandas-datareader不是必须的但如果你想用Stooq做数据校验它会比手工下载CSV舒服很多。另外提醒一句如果你本地Python环境比较旧yfinance升到最新版后可能有奇怪的依赖冲突建议直接用Python 3.9以上版本坑会少很多。把依赖和目录结构前置处理好的价值在后续调试阶段会非常明显。你不需要每次换一个分析想法就重装一遍环境数据和图表也能集中沉淀而不是散落在各个临时目录里。2.2 用yfinance拉取日经225与东证主板个股第一步先拉日经225指数脚本很短import yfinance as yf # 拉取日经225指数时间范围为2015-2024 nk yf.download( ^N225, start2015-01-01, end2024-12-31, auto_adjustTrue ) nk.to_csv(data/nikkei225.csv) print(nk.tail())代码里最重要的参数是auto_adjustTrue。打开这个开关后yfinance返回的Close、Open、High、Low都是复权价格省去了自己做复权计算的麻烦。如果你需要原始价格就把它设成False但请注意后续计算收益率时必须用复权后的序列。对于具体股票yfinance的符号规则是“东京证券代码 .T”。比如丰田汽车是7203.T索尼是6758.T三菱UFJ是8306.T。如果要同时拉三只股票可以这样写import yfinance as yf tickers [7203.T, 6758.T, 8306.T] prices yf.download( tickers, start2015-01-01, end2024-12-31, auto_adjustTrue )[Close] prices.to_csv(data/topix_stocks.csv) print(prices.tail())注意多只股票同时下载时返回的列会变成MultiIndex结构所以后面要接[Close]只留下收盘价矩阵。这一步很容易被忽略我第一次跑的时候直接print(prices)看到一堆多维列名还以为是数据出错了。如果发现某只股票拉不到先确认代码有没有写错。日经指数、东证指数这类指数的符号在不同数据源之间并不统一yfinance用^N225Stooq可能用^NKX以实际返回为准。2.3 用Stooq交叉校验主数据源挂了也不慌yfinance毕竟不是官方数据源偶尔会遇到个别数据缺失或者价格异常的情况。所以我习惯再用Stooq拉同一时间段的数据做交叉验证。from pandas_datareader import data as pdr # 从Stooq获取日经225指数 nk_stooq pdr.DataReader( ^NKX, stooq, start2015-01-01, end2024-12-31 ) # Stooq返回的数据经常是倒序的务必按日期升序排一下 nk_stooq nk_stooq.sort_index() print(nk_stooq.tail())这里有个很实用的细节Stooq返回的数据很多情况下是倒序的日期最新的一天排在前面。如果不做sort_index()后面合并数据、计算收益率会全部错位。我甚至建议无论用什么数据源拉完数据后先看一眼索引顺序再决定要不要排序成本极低但能避免很多隐性问题。校验方法很简单把两个数据源同一天的收盘价放在一起计算差异比例。正常来说差异应该非常小如果某个时间段差异超过1%就要警惕数据源里的复权逻辑或者拼接逻辑可能不一致。这种交叉验证的习惯远比事后对着错误结果找原因省时间。3. 数据清洗与预处理拿到DataFrame先别急着算3.1 缺失值、停牌日与异常跳空把数据拉回本地之后先做一次“数据体检”别急着算收益率。import pandas as pd prices pd.read_csv(data/topix_stocks.csv, index_col0, parse_datesTrue) print(prices.isnull().sum())日本个股存在停牌日比如发布重大公告时临时停牌所以你会看到某些行的具体字段是NaN。处理方式取决于场景如果是计算收益率可以直接dropna()因为停牌日本身没有交易如果是需要连续对齐日期做合并可以用ffill()把上一个交易日的价格填充过去。还要警惕异常跳空。日本股票虽然有涨跌停机制但极端行情下依然会出现超过10%的单日波动。写一个过滤器把这些跳空找出来returns prices.pct_change() jumps returns[returns.abs() 0.1].dropna(howall) print(jumps)找到异常交易日之后去新闻里看一眼原因。2020年全球股市剧烈波动那段时间东证主板出现单日大幅下跌很正常不是数据错误。但如果你在某个普通日子发现10%以上的跳空且找不到新闻那八成是复权口没设置好赶紧回头检查配置。3.2 复权价格与原始价格到底用哪个一次性说清这个问题几乎每个做股票数据分析的人都会纠结一遍我直接说结论做收益率计算、策略回测、波动率分析统一用复权价格。画“实际K线图”、给不懂复权的业务同事看展示图才用原始价格。原因是复权价格在除权除息当天不会出现人为跳空。举个例子某公司股价2000日元宣布每股派息50日元。除息日当天开盘价大约会降至1950日元左右同时你收到了每股50日元的现金。如果直接拿原始价格算收益率这一天会显示亏损2.5%但你的实际账户资产并没有减少。复权价格会把历史价格按当前口径重新调整保证收益率连续、真实。在yfinance里auto_adjustTrue返回的Close已经是复权价。如果你拉数据时没开这个参数也可以后期用pandas的ffill()和除权因子手动处理但非常麻烦不如重新拉一次数据。3.3 把时间戳对齐到东京时间日线数据的时区处理日线数据看起来简单实则时区坑最多。很多数据源返回的日期是UTC时区下的日期或者根本没有时区信息。如果你只做纯日线分析时间戳的“日期部分”通常已经按交易日对齐问题不大。但当你把两个不同数据源的数据合并时一个带时区、一个不带时区会出现索引类型不匹配pandas.concat直接报错。稳妥的做法是统一转成东京时间# 如果索引是带时区的UTC时间 prices.index prices.index.tz_convert(Asia/Tokyo) # 如果索引是不带时区的日期直接指定为东京时间 prices.index prices.index.tz_localize(Asia/Tokyo)注意不能用tz_localize去处理一个已经带时区的索引会抛错。所以先print(prices.index)看清楚类型再操作。还有一个容易踩的坑是有人习惯手动对时间加9小时比如index index pd.Timedelta(hours9)。日本没有夏令时手动加9小时确实能凑对但这个写法在代码评审里非常容易被挑战不如直接用专有时区对象语义清晰且不容易出错。4. 深度分析从价格序列到可执行结论4.1 收益、波动率、夏普比率完整算一遍拿到清洗好的收盘价矩阵之后第一步当然是算收益率和风险指标。import numpy as np import pandas as pd prices pd.read_csv(data/topix_stocks.csv, index_col0, parse_datesTrue) returns prices.pct_change().dropna() # 年化收益与年化波动率日经一年约252个交易日 annual_return returns.mean() * 252 annual_vol returns.std() * np.sqrt(252) sharpe annual_return / annual_vol result pd.DataFrame({ 年化收益: annual_return, 年化波动率: annual_vol, 夏普比率: sharpe }) print(result.round(4))为什么年化系数用252而不是365因为股票只在工作日交易日本市场一年大概有252个交易日。按365算会把周末的无风险收益重复计算进去导致年化波动率偏高、夏普比率偏低。如果想观察波动率随时间的变化可以算20日滚动波动率rolling_vol returns.rolling(20).std() * np.sqrt(252) * 100 rolling_vol.plot()滚动波动率的重要价值在于它能直观展示市场风险偏好的切换。比如2015年中国股市波动、2020年全球疫情冲击、2022年美联储加息周期这些事件都会在日本市场上留下明显的波动率波峰。你不需要记历史事件光看图就能定位出哪些时间段市场“不好过”。4.2 相关性矩阵与板块联动三只股票的数据矩阵正好可以看不同行业之间的联动性。我选了汽车、消费电子、银行三个方向算相关系数corr returns.corr() print(corr.round(4))丰田、索尼、三菱UFJ三者之间的相关性通常不会太高。汽车股受全球销量和汇率影响较大索尼更受消费电子周期驱动银行股则直接跟日本国债收益率挂钩。一个组合里如果股票之间相关性低整体净值曲线会更平滑回撤也会小一些。为了看得更细可以算滚动相关性rolling_corr returns[7203.T].rolling(60).corr(returns[6758.T]) print(rolling_corr.describe())滚动相关性可以看出两只股票的关系在不同市场环境下是否稳定。有些股票平时相关系数很低一到市场大跌时相关性飙升这在行业内叫“危机相关性”是做仓位控制时必须考虑的风险。4.3 小回测双均线策略在东证指数上的真实表现分析历史数据最终要落到“能不能用”上。我用日经225指数做了一版最简单的双均线策略回测20日均线上穿60日均线时持有下穿时空仓。import numpy as np import pandas as pd nk pd.read_csv(data/nikkei225.csv, index_col0, parse_datesTrue) df nk[[Close]].copy() df[SMA20] df[Close].rolling(20).mean() df[SMA60] df[Close].rolling(60).mean() # 信号生成 df[Position] np.where(df[SMA20] df[SMA60], 1, 0) # 策略收益注意信号要shift(1)防止用到当天收盘后的未来信号 df[Market] df[Close].pct_change() df[Strategy] df[Position].shift(1) * df[Market] # 累计净值 df[CumMarket] (1 df[Market]).cumprod() df[CumStrategy] (1 df[Strategy]).cumprod() print(df[[CumMarket, CumStrategy]].tail())这里最关键的代码就是shift(1)。均线信号是在当天收盘后才能计算的你最快也只能在下一个交易日开盘时执行。如果不做shift(1)策略会“未来函数”等于让策略提前偷看了明天才知道的信号回测收益率会非常好看实盘却完全没法复现。几乎所有新手回测绩效虚高都栽在这一行上。再算最大回撤cum df[CumStrategy] peak cum.cummax() drawdown (cum - peak) / peak max_drawdown drawdown.min() print(最大回撤:, round(max_drawdown * 100, 2), %)回测结果大概率会让你失望双均线策略在日经225上多数时候跑不赢简单买入持有。这是正常现象趋势跟踪策略在震荡市里会被反复打脸。但跑这个回测的价值恰恰是让你建立“先验证再实盘”的纪律。等你想出更复杂的策略时这套回测框架只需要换信号逻辑其他地方原样复用。4.4 等权组合试验比单押一只股票省心把丰田、索尼、三菱UFJ三只股票做成等权组合每天重新平衡portfolio_ret returns.mean(axis1) portfolio_cum (1 portfolio_ret).cumprod() stock_cum (1 returns).cumprod() # 比较组合与单只股票的累计净值 comparison pd.DataFrame({ 组合: portfolio_cum, 丰田: stock_cum[7203.T], 索尼: stock_cum[6758.T], 三菱UFJ: stock_cum[8306.T] }) print(comparison.tail())等权组合的核心价值在于分散。三只股票相关性不高某一年汽车行业承压的时候银行股可能正好受益于利率上行整体净值波动会明显低于任何单只股票。这就是为什么很多机构研究员的报告里经常强调“组合视角”而不是单票视角。5. API调用高频报错实录把常见的坑一次排光5.1 401未授权与incorrect api key认证失败的几类真因不管是拉行情数据还是后面接大模型API做文本分析API Key认证永远是报错重灾区。最典型的一种报错是unexpected status 401 unauthorized: incorrect api key provided遇到这种报错我建议按这个顺序排查检查密钥是否完整复制。很多平台的密钥很长复制时容易漏掉最后几位或者把提示信息里的sk-...占位符当成真实密钥。检查环境变量是否生效。如果用了.env文件改完密钥后一定要重新启动终端进程某些运行时不会自动刷新环境变量。检查密钥开头和结尾字符。很多平台会在后台显示部分密钥比如sk-svcac****这是脱敏后的展示不是完整密钥。拿echo $TOKYO_API_KEY | wc -c看一下环境变量实际长度对比后台显示的密钥长度能快速定位是否截断。我的习惯是把密钥放在环境变量里代码里只读取环境变量不硬编码。这样即使代码被传到公开仓库也不会泄露敏感信息。5.2 400请求格式错误从上下文超长到时间范围过大另一个高频报错是api error: 400 this models maximum context length is 1048576 tokens...虽然这句提示来自大模型API但它反射的是一个通用性质的问题你给服务端传的参数超过了它能处理的上限。放在行情数据场景里对应的问题是请求时间跨度太大或者返回字段过多。比如使用某些历史数据接口一次请求十年每日数据可能直接被400拒绝。解决办法是分年份拉取再合并import pandas as pd from datetime import datetime frames [] for year in range(2015, 2025): df fetch_history( startdatetime(year, 1, 1), enddatetime(year, 12, 31) ) frames.append(df) time.sleep(1) full pd.concat(frames)分片下载虽然看起来多写了几行代码但能显著降低单次请求负载对服务端友好对自己也省去被限流的麻烦。5.3 429限流、500服务端错误、连接中断的排查顺序数据类API还经常遇到下面几类报错我整理成了一张速查表报错特征大概率原因处理思路429 Too Many Requests请求频率超过限额加sleep间隔采用指数退避重试避免循环里同步高频请求500 Internal Server Error服务端临时异常先等待几秒重试如果持续出现检查是否传了不支持的新参数connection dropped (ECONNRESET)网络环境不稳定或连接被中途断开缩小请求批次适当增加超时时间用重试库封装400 organization disabled账号或组织被停用去控制台检查账号状态而不是继续调接口对于偶发性的网络和限流问题我强烈建议直接引入tenacity重试库样例代码如下import time from tenacity import retry, stop_after_attempt, wait_exponential retry( stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max10) ) def fetch_with_retry(func, *args, **kwargs): return func(*args, **kwargs)重试逻辑看起来简单但有一个细节很重要重试不要用固定间隔而是用指数退避。第一次失败后等2秒第二次等4秒第三次等8秒。服务端限流往往是因为你请求太密集继续按固定间隔重试会加重负载指数退避能让服务端有时间恢复。5.4 密钥过期与账号被禁用我踩过的最贵一次坑有一类报错和代码完全无关纯粹是账号状态问题。比如unexpected status 401 unauthorized: authentication fails, your api key: ****这里的密钥可能曾经有效但后来过期了、被重置了或者账号权限被调整了。我在一次数据分析项目中途遇到过类似问题排查了半天代码最后才发现是密钥十天前已经过期而因为项目是每天定时跑的直到某一天重跑任务时才暴露出来。所以现在我在任何脚本开头都会加一个简单的状态检查用一个最小的请求验证密钥是否有效无效立刻结束任务并报警。别等跑到数据分析最后一步才发现整个数据源早就断了那种返工成本真的很高。6. 把分析结果可视化让数字自己说话6.1 净值曲线、策略信号与回撤区域图数据算完不画图等于白算。可视化不只是为了好看它能帮你一眼看出策略在哪些时间段失效。import matplotlib.pyplot as plt plt.rcParams[figure.dpi] 120 fig, ax plt.subplots(3, 1, figsize(12, 10), sharexTrue) # 净值对比 ax[0].plot(df[CumMarket], labelBuy Hold, linewidth1.2) ax[0].plot(df[CumStrategy], labelDual MA, linewidth1.2) ax[0].set_title(Nikkei 225: Market vs Strategy) ax[0].legend() # 回撤 cum df[CumStrategy] peak cum.cummax() drawdown (cum - peak) / peak ax[1].fill_between(drawdown.index, drawdown * 100, 0, colorred, alpha0.4) ax[1].set_title(Strategy Drawdown (%)) # 价格和均线 ax[2].plot(df[Close], labelClose, linewidth1) ax[2].plot(df[SMA20], labelSMA20, linewidth0.8) ax[2].plot(df[SMA60], labelSMA60, linewidth0.8) ax[2].legend() plt.tight_layout() plt.savefig(output/nikkei_dual_ma_result.png) plt.show()从回撤图上你会很直观地看到双均线策略在单边下跌行情里确实能躲开一部分亏损但在震荡市里频繁止损净值曲线被反复摩擦。看到这个结果你就不会再迷信任何不看回撤的策略报告了。6.2 相关性热力图与滚动波动率相关性矩阵用热力图呈现最清晰import seaborn as sns plt.figure(figsize(6, 5)) sns.heatmap(corr, annotTrue, cmapcoolwarm, center0, fmt.3f) plt.title(Correlation Matrix of Three TSE Stocks) plt.tight_layout() plt.savefig(output/correlation_heatmap.png) plt.show()热力图上如果相关性数字集中在0.3到0.6之间说明这几个行业之间确实存在一定同步性但长期走势还是由各自基本面驱动。组合配置时别只盯着相关系数还要看波动率。两个低相关但高波动的资产组合在一起分散效果也是有限的。滚动波动率图用折线就够了重点观察几个市场恐慌时间段的波动峰值。把波动率曲线和净值图放在同一个时间段看你会更理解“先控回撤再谈收益”这句话在量化里的分量。最后分享一个实操习惯这套流程我跑了不止一遍每一次重新拉数据都会有新的小问题冒出来比如某个时间段数据源改版、某只股票代码变更、复权因子调整。所以我现在基本上把拉数脚本做成了一个独立的模块每天早上定时执行落地为标准化CSV后续所有研究只基于这个CSV而不是反复触发API。这样既省了调用额度也大幅降低了自己被限流的概率。如果你只是临时跑一次分析直接把脚本复制下来用就行但如果你有长期研究计划强烈建议把数据采集与分析拆成两层持久化先做好分析永远基于本地数据。这一小小的架构调整能让你的整个研究过程省掉非常多无谓的调试时间。