基于购买力平价(PPP)和 IMF 数据确定公允汇率·进阶篇
(2/3)· 扔掉滞后半年的 OECD 报表,用相对购买力平价在 Python 里算出货币的真正内在价值
抓取 IMF 数据前先把映射坑填平
做跨市场购买力平价测算,第一道坎不是算法而是数据源。IMF 的 REST 接口公开免费且文档完整,但货币代码和国家代码不是一一对应:EUR 在库里是 U2,瑞士是 CH 不是 SW,英国是 GB 不是 UK。我曾耗掉一整天查不存在的国家码,系统跑出来全是空。 用 requests.Session() 而不是单次 get(),能复用 TCP 连接,对同一 API 连续拉数时延迟明显下降。User-Agent 也别留 Python 默认值,很多接口会直接拦,写明应用标识更稳。 外部 API 宕机、回残缺数据、无理由报错都是常态。我内置了一套兜底市场汇率,例如 EURUSD 1.0850、USDJPY 148.50、USDCHF 0.8850,基于最近可得价格比对与撰文时市价,非实时但够系统自洽。金融系统里可靠性优先于完美精度,大致对总比没有强。 GDP 隐含法要美元计 GDP,只取世界银行的市场汇率口径,不能混进购买力平价美元。通胀调整法以 2020 年均价做基准——那年距现在够近、且早于疫情极端货币政策、数据已定稿不会修订。下方初始化代码把映射、兜底汇率、2024 价格水平、2023 GDP 估算、2020 基准汇率全塞进对象里,开 Python 直接能跑。
def __init__(self): self.base_url = "http:class=class="str">"cmt">//dataservices.imf.org/REST/SDMX_JSON.svc" self.session = requests.Session() self.session.headers.update({ &class="macro">#x27;User-Agent&class="macro">#x27;: &class="macro">#x27;Manual-PPP-Calculator/class="num">1.0&class="macro">#x27;, &class="macro">#x27;Accept&class="macro">#x27;: &class="macro">#x27;application/json&class="macro">#x27; }) self.currency_country_map = { &class="macro">#x27;USD&class="macro">#x27;: &class="macro">#x27;US&class="macro">#x27;, &class="macro">#x27;EUR&class="macro">#x27;: &class="macro">#x27;U2&class="macro">#x27;, &class="macro">#x27;GBP&class="macro">#x27;: &class="macro">#x27;GB&class="macro">#x27;, &class="macro">#x27;JPY&class="macro">#x27;: &class="macro">#x27;JP&class="macro">#x27;, &class="macro">#x27;AUD&class="macro">#x27;: &class="macro">#x27;AU&class="macro">#x27;, &class="macro">#x27;CAD&class="macro">#x27;: &class="macro">#x27;CA&class="macro">#x27;, &class="macro">#x27;CHF&class="macro">#x27;: &class="macro">#x27;CH&class="macro">#x27;, &class="macro">#x27;NZD&class="macro">#x27;: &class="macro">#x27;NZ&class="macro">#x27;, &class="macro">#x27;SEK&class="macro">#x27;: &class="macro">#x27;SE&class="macro">#x27;, &class="macro">#x27;NOK&class="macro">#x27;: &class="macro">#x27;NO&class="macro">#x27;, &class="macro">#x27;DKK&class="macro">#x27;: &class="macro">#x27;DK&class="macro">#x27;, &class="macro">#x27;PLN&class="macro">#x27;: &class="macro">#x27;PL&class="macro">#x27; } self.fallback_market_rates = { &class="macro">#x27;EURUSD&class="macro">#x27;: class="num">1.0850, &class="macro">#x27;GBPUSD&class="macro">#x27;: class="num">1.2650, &class="macro">#x27;USDJPY&class="macro">#x27;: class="num">148.50, &class="macro">#x27;AUDUSD&class="macro">#x27;: class="num">0.6750, &class="macro">#x27;USDCAD&class="macro">#x27;: class="num">1.3550, &class="macro">#x27;USDCHF&class="macro">#x27;: class="num">0.8850, &class="macro">#x27;NZDUSD&class="macro">#x27;: class="num">0.6150 } self.price_levels_2024 = { &class="macro">#x27;US&class="macro">#x27;: class="num">100.0, # Basic level &class="macro">#x27;U2&class="macro">#x27;: class="num">88.5, # Eurozone is class="num">11.5% cheaper than the US &class="macro">#x27;GB&class="macro">#x27;: class="num">85.2, # UK &class="macro">#x27;JP&class="macro">#x27;: class="num">67.4, # Japan is significantly cheaper &class="macro">#x27;AU&class="macro">#x27;: class="num">95.8, # Australia is close to the USA &class="macro">#x27;CA&class="macro">#x27;: class="num">91.3, # Canada is moderately cheaper &class="macro">#x27;CH&class="macro">#x27;: class="num">125.6, # Switzerland is the most expensive &class="macro">#x27;NZ&class="macro">#x27;: class="num">89.7 # New Zealand } self.gdp_usd_estimates_2023 = { &class="macro">#x27;US&class="macro">#x27;: class="num">27000, # USD class="num">27 trillion &class="macro">#x27;U2&class="macro">#x27;: class="num">17500, # Eurozone ~USD class="num">17.5 trillion &class="macro">#x27;GB&class="macro">#x27;: class="num">3300, # UK ~USD class="num">3.3 trillion &class="macro">#x27;JP&class="macro">#x27;: class="num">4200, # Japan ~USD class="num">4.2 trillion &class="macro">#x27;AU&class="macro">#x27;: class="num">1700, # Australia ~USD class="num">1.7 trillion &class="macro">#x27;CA&class="macro">#x27;: class="num">2100, # Canada ~USD class="num">2.1 trillion &class="macro">#x27;CH&class="macro">#x27;: class="num">900, # Switzerland ~USD class="num">0.9 trillion &class="macro">#x27;NZ&class="macro">#x27;: class="num">250 # New Zealand ~USD class="num">0.25 trillion } self.base_rates_2020 = { &class="macro">#x27;U2&class="macro">#x27;: class="num">0.85, # EURUSD &class="macro">#x27;GB&class="macro">#x27;: class="num">0.78, # GBPUSD &class="macro">#x27;JP&class="macro">#x27;: class="num">106.0, # USDJPY &class="macro">#x27;AU&class="macro">#x27;: class="num">1.45, # AUDUSD &class="macro">#x27;CA&class="macro">#x27;: class="num">1.34, # USDCAD }
◍ 货币对权重里的隐性偏差
上面这段映射把 USDCHF 标成 0.92、NZDUSD 标成 1.52,表面只是代号配系数,实际是在给多货币篮子里的波动贡献度定权重。 USDCHF 取 0.92 意味着瑞郎对美元的反向波动被打了约 8% 的折让,而 NZDUSD 的 1.52 说明纽元被赋予了比基准高 52% 的敏感度。 在 MT5 里直接把这段塞进指标或 EA 的权重表,回测时就能看到:同样 1% 的美元指数变动,纽元腿比瑞郎腿甩得远得多。外汇与贵金属杠杆高,权重设错一边就可能把回撤放大数倍,调参前先跑一遍单货币验证。
&class="macro">#x27;CH&class="macro">#x27;: class="num">0.92, # USDCHF &class="macro">#x27;NZ&class="macro">#x27;: class="num">1.52 # NZDUSD }
「使用 IMF API 的难点」
任何金融项目中最费力却又至关重要的部分,就是与外部数据源打交道。IMF API 功能强大、信息丰富,但也有它的怪癖,需要反复试错才能摸清。 我遇到的第一个问题是,这个 API 不喜欢大型请求。如果你试图一次性为多个国家请求多个指标,服务器要么返回错误,要么超时。我不得不实现将请求拆分成小部分的功能。 分块大小是我通过经验确定的。3 个指标太保守了,需要发起大量请求。7-8 个指标 —— 经常导致超时。5 个被证明是最优的折中方案。 IMF API 有时表现得不可预测。同样的请求早上可能成功,晚上就失败了。有时服务器在没有任何警告的情况下返回部分数据。有时数据以意想不到的格式到达。 我添加了一套完善的错误处理系统并配有日志记录: 如果没有这样详细的日志记录,调试将是一场噩梦。当请求失败时,你需要弄清楚它发生在哪个阶段、为什么失败。 IMF API 的响应格式值得特别一提。他们使用 SDMX-JSON,这是一种统计数据交换的 "标准" 格式。实际上,这用起来相当痛苦。 SDMX-JSON 中的所有属性都以 '@' 符号标记,这给数据处理带来了不便。此外,API 可能将一个数据序列作为对象返回,也可能将多个序列作为数组返回 —— 这也需要处理。 另一个问题:有时 API 返回的数据没有 'Obs' 部分,有时 'Obs' 为空,有时 'Obs' 包含的不是列表而是单个对象。每种情况都需要单独处理。 如果无法获取最新的通胀数据,我会使用各国的典型值: 这些数据是我从各种来源收集的,并对 2020-2024 年期间的数据取了平均值。它们并非完全准确,但为数据缺失的国家提供了合理的近似值。 [CODE] <span class="keyword">def</span> fetch_all_available_data(self, countries: <span>List</span>[<span class="built_in">str</span>], years: <span class="built_in">int</span> = <span class="number">10</span>): all_indicators = [ <span class="string">'NGDP_XDC'</span>, <span class="comment"># GDP in national currency</span> <span class="string">'NGDP_USD'</span>, <span class="comment"># GDP in USD</span> <span class="string">'PCPIPCH'</span>, <span class="comment"># Inflation rate</span>
把 SDMX-JSON 压成可用 DataFrame 的坑
接数据接口时,宏观数据源常返回 SDMX-JSON 结构,字段名前带 @ 符号,且单序列可能以对象或数组两种形态出现。若不做归一化,pandas 解析会直接报错或漏数据。 下面这段 Python 是处理 CompactData 节点的核心:先取 DataSet,判断 Series 是否存在;不存在就返回空表。Series 和 Obs 都做了「非列表则包成列表」的兼容,避免单条记录时迭代失败。 def _parse_response_data(self, data: Dict) -> pd.DataFrame: records = [] try: compact_data = data['CompactData'] dataset = compact_data['DataSet'] if 'Series' not in dataset: return pd.DataFrame() series_list = dataset['Series'] # API can return a single series as an object or an array of series as a list if not isinstance(series_list, list): series_list = [series_list] for series in series_list: # All attributes are marked with the '@' symbol - this needs to be processed series_attrs = {k.replace('@', ''): v for k, v in series.items() if k.startswith('@')} obs_list = series.get('Obs', []) if not isinstance(obs_list, list): obs_list = [obs_list] for obs in obs_list: if isinstance(obs, dict): record = series_attrs.copy() record.update({ 'year': obs.get('@TIME_PERIOD', ''), 'value': obs.get('@OBS_VALUE', ''), 'status': obs.get('@OBS_STATUS', '') }) records.append(record) df = pd.DataFrame(records) if 'value' in df.columns: df['value'] = pd.to_numeric(df['value'], errors='coerce') if 'year' in df.columns: df['year'] = pd.to_numeric(df['year'], errors='coerce') return df except Exception as e: logger.error(f"Error parsing SDMX-JSON response: {e}") 逐行看:series_attrs 用字典推导把 @ 前缀剥掉,仅保留属性字段;Obs 内每条记录补 year / value / status 三列,空值给 '' 而非 None,后续 to_numeric 用 coerce 把脏数据转 NaN。 实跑时若日志抛出 Error parsing SDMX-JSON response,优先查返回的 Series 是否藏在 DataSet 的子字典而非直列。外汇与贵金属受宏观数据跳变影响大,这类接口脏数据可能引发误信号,接入后建议先用近 3 年月度数据做一遍空值率统计再上策略。
def _parse_response_data(self, data: Dict) -> pd.DataFrame: records = [] try: compact_data = data[&class="macro">#x27;CompactData&class="macro">#x27;] dataset = compact_data[&class="macro">#x27;DataSet&class="macro">#x27;] if &class="macro">#x27;Series&class="macro">#x27; not in dataset: class="kw">return pd.DataFrame() series_list = dataset[&class="macro">#x27;Series&class="macro">#x27;] # API can class="kw">return a single series as an object or an array of series as a list if not isinstance(series_list, list): series_list = [series_list] for series in series_list: # All attributes are marked with the &class="macro">#x27;@&class="macro">#x27; symbol - this needs to be processed series_attrs = {k.replace(&class="macro">#x27;@&class="macro">#x27;, &class="macro">#x27;&class="macro">#x27;): v for k, v in series.items() if k.startswith(&class="macro">#x27;@&class="macro">#x27;)} obs_list = series.get(&class="macro">#x27;Obs&class="macro">#x27;, []) if not isinstance(obs_list, list): obs_list = [obs_list] for obs in obs_list: if isinstance(obs, dict): record = series_attrs.copy() record.update({ &class="macro">#x27;year&class="macro">#x27;: obs.get(&class="macro">#x27;@TIME_PERIOD&class="macro">#x27;, &class="macro">#x27;&class="macro">#x27;), &class="macro">#x27;value&class="macro">#x27;: obs.get(&class="macro">#x27;@OBS_VALUE&class="macro">#x27;, &class="macro">#x27;&class="macro">#x27;), &class="macro">#x27;status&class="macro">#x27;: obs.get(&class="macro">#x27;@OBS_STATUS&class="macro">#x27;, &class="macro">#x27;&class="macro">#x27;) }) records.append(record) df = pd.DataFrame(records) if &class="macro">#x27;value&class="macro">#x27; in df.columns: df[&class="macro">#x27;value&class="macro">#x27;] = pd.to_numeric(df[&class="macro">#x27;value&class="macro">#x27;], errors=&class="macro">#x27;coerce&class="macro">#x27;) if &class="macro">#x27;year&class="macro">#x27; in df.columns: df[&class="macro">#x27;year&class="macro">#x27;] = pd.to_numeric(df[&class="macro">#x27;year&class="macro">#x27;], errors=&class="macro">#x27;coerce&class="macro">#x27;) class="kw">return df except Exception as e: logger.error(f"Error parsing SDMX-JSON response: {e}")
◍ 用通胀差近似修正各国基准利率
这段 Python 逻辑给 2020 年各国 base rate 做了一次通胀差近似修正,核心假设是美通胀与其他经济体通胀的差值会线性拖累或抬升实际利率。典型通胀取值来自 2020–2024 历史区间:美国 4.5%、欧元区 3.8%、英国 4.2%、日本 1.8%、澳洲 4.1%、加拿大 3.9%、瑞士 2.1%、新西兰 4.0%。 修正公式为 adjustment_factor = 1 + (us_inflation - country_inflation)/100,再乘原 base_rate。例如日本通胀差 = 4.5 - 1.8 = 2.7pp,因子 1.027,其 2020 基准若取 0 附近则调整后仍近零,说明日美实际利差在样本期被通胀进一步压窄。 做外汇或贵金属交叉盘时,这类近似能快速给出一个「倾向性」的实际利率视角,但高风险在于它忽略货币政策路径与期限结构,只能当初筛不能当入场依据。
class="kw">return pd.DataFrame() def _approximate_inflation_adjustment(self) -> Dict: logger.info("Using approximate inflation adjustment...") # Typical inflation rates class="num">2020-class="num">2024 (based on historical data) typical_inflation = { &class="macro">#x27;US&class="macro">#x27;: class="num">4.5, # US: Relatively high inflation due to stimulus &class="macro">#x27;U2&class="macro">#x27;: class="num">3.8, # Eurozone: Moderate inflation &class="macro">#x27;GB&class="macro">#x27;: class="num">4.2, # UK: Brexit + energy crisis &class="macro">#x27;JP&class="macro">#x27;: class="num">1.8, # Japan: Traditionally low inflation &class="macro">#x27;AU&class="macro">#x27;: class="num">4.1, # Australia: Commodity inflation &class="macro">#x27;CA&class="macro">#x27;: class="num">3.9, # Canada: close to the US &class="macro">#x27;CH&class="macro">#x27;: class="num">2.1, # Switzerland: Low inflation &class="macro">#x27;NZ&class="macro">#x27;: class="num">4.0 # New Zealand: Moderate inflation } inflation_adjusted = {} for country, base_rate in self.base_rates_2020.items(): us_inflation = typical_inflation.get(&class="macro">#x27;US&class="macro">#x27;, class="num">4.5) country_inflation = typical_inflation.get(country, class="num">3.5) inflation_differential = us_inflation - country_inflation adjustment_factor = class="num">1 + (inflation_differential / class="num">100) adjusted_rate = base_rate * adjustment_factor inflation_adjusted[country] = { &class="macro">#x27;inflation_adjusted_rate&class="macro">#x27;: adjusted_rate, &class="macro">#x27;base_rate_2020&class="macro">#x27;: base_rate, &class="macro">#x27;inflation_differential&class="macro">#x27;: inflation_differential, &class="macro">#x27;method&class="macro">#x27;: &class="macro">#x27;approximate_inflation&class="macro">#x27; } logger.info(f"{country}: Approx inflation diff {inflation_differential:+.2f}pp") class="kw">return inflation_adjusted
「从购买力平价值到可排序的信号」
算出各币种相对美元的 PPP 汇率只是半截事。要把学术数字变成能下手的信号,得先拼出交叉盘的公允汇率:欧元对美元 PPP 0.885、英镑对美元 0.852,则 EURGBP 公允值 = 0.885 / 0.852 ≈ 1.039,再和实时市场价比出偏离百分比。 历史回看里,5% 与 15% 是两个靠经验切出来的阈值。低于 5% 的偏离大概率只是噪声,不值得开仓;冲到 15% 则说明货币对已进入严重失衡区,需要重点盯。 信号不能只看偏离幅度。把「偏离度 × 信心指数」作为信号强度,才能横向排序:偏离 20% 但信心仅 50%(强度 10)的机会,吸引力可能不如偏离 15% 但信心 80%(强度 12)的那个。外汇与贵金属杠杆高,这类均值回归假设随时可能被宏观冲击打断,实盘前请在 MT5 用历史数据自测阈值。 数据缺口是常态——瑞士有 GDP 没通胀,新西兰有通胀没最新 GDP。直接剔除不完整国家会导致几乎全市场不可用,所以改成优雅降级:缺哪类就少算哪类,用现有数据推近似公允值,再标低信心指数。下面这段 Python 伪码展示了从货币对反查国家、拉可用数据、算公允值与偏离的骨架。 def calculate_ppp_fair_values(self, currency_pairs: List[str]) -> Dict: logger.info("Starting manual PPP fair value calculation...") # 确定需要哪些国家 countries = set() for pair in currency_pairs: base_currency = pair[:3] quote_currency = pair[3:] base_country = self.currency_country_map.get(base_currency) quote_country = self.currency_country_map.get(quote_currency) if base_country and quote_country: countries.add(base_country) countries.add(quote_country) logger.info(f"Will analyze {len(countries)} countries for {len(currency_pairs)} pairs") # 载入经济数据 economic_data = self.fetch_all_available_data(list(countries)) # 用全部方法算 PPP ppp_calculation_results = self.calculate_manual_ppp_rates(economic_data) # 取当前市场汇率 market_rates = self.fallback_market_rates # 真实系统里这里接行情 API # 结果结构 results = { 'ppp_calculation_methods': ppp_calculation_results, 'fair_values': {}, 'deviations': {}, 'market_rates': market_rates, 'summary': {} } composite_ppp = ppp_calculation_results.get('composite_ppp_rates', {}) # 逐对算公允值与偏离 for pair in currency_pairs: base_currency = pair[:3] quote_currency = pair[3:] base_country = self.currency_country_map.get(base_currency) quote_country = self.currency_country_map.get(quote_currency) if not base_country or not quote_country: logger.warning(f"Cannot map currencies for {pair}") continue # 算公允汇率 fair_value = self._calculate_pair_fair_value_from_ppp( composite_ppp, base_country, quote_country, pair ) if fair_value: results['fair_values'][pair] = fair_value # 与市场汇率比较 market_rate = market_rates.get(pair)
def calculate_ppp_fair_values(self, currency_pairs: List[str]) -> Dict: logger.info("Starting manual PPP fair value calculation...") # Determine which countries we need countries = set() for pair in currency_pairs: base_currency = pair[:class="num">3] quote_currency = pair[class="num">3:] base_country = self.currency_country_map.get(base_currency) quote_country = self.currency_country_map.get(quote_currency) if base_country and quote_country: countries.add(base_country) countries.add(quote_country) logger.info(f"Will analyze {len(countries)} countries for {len(currency_pairs)} pairs") # Load economic data economic_data = self.fetch_all_available_data(list(countries)) # Calculate PPP using all methods ppp_calculation_results = self.calculate_manual_ppp_rates(economic_data) # Get current market rates market_rates = self.fallback_market_rates # In the real system, there is a market data API here # Results structure results = { &class="macro">#x27;ppp_calculation_methods&class="macro">#x27;: ppp_calculation_results, &class="macro">#x27;fair_values&class="macro">#x27;: {}, &class="macro">#x27;deviations&class="macro">#x27;: {}, &class="macro">#x27;market_rates&class="macro">#x27;: market_rates, &class="macro">#x27;summary&class="macro">#x27;: {} } composite_ppp = ppp_calculation_results.get(&class="macro">#x27;composite_ppp_rates&class="macro">#x27;, {}) # Calculate fair rates and deviations for each pair for pair in currency_pairs: base_currency = pair[:class="num">3] quote_currency = pair[class="num">3:] base_country = self.currency_country_map.get(base_currency) quote_country = self.currency_country_map.get(quote_currency) if not base_country or not quote_country: logger.warning(f"Cannot map currencies for {pair}") class="kw">continue # Calculate a fair exchange rate fair_value = self._calculate_pair_fair_value_from_ppp( composite_ppp, base_country, quote_country, pair ) if fair_value: results[&class="macro">#x27;fair_values&class="macro">#x27;][pair] = fair_value # Compare with the market rate market_rate = market_rates.get(pair)