LMT/RTX Defense Sector Pair Trading Strategy

Row-Level Dual-Model with Liquidity, Seasonality, and Real-Rate Signals, 3-Day Hold

Author
Affiliation

Rusty Conover

Query.Farm

Published

April 16, 2026

Show code
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')

import sys
sys.path.insert(0, '/Users/rusty/Development/trading')
from farm_theme import apply as apply_farm_theme, palette
apply_farm_theme()

df = pd.read_csv('strategy_data.csv', parse_dates=['dt'])
df = df.sort_values('dt').reset_index(drop=True)

capital = 10000
ret_col = 'daily_ret_unscaled'
df['cum_pnl'] = (df[ret_col] * capital).cumsum()
df['drawdown'] = df['cum_pnl'] - df['cum_pnl'].cummax()
df['year'] = df['dt'].dt.year

lmt = pd.read_csv('LMT.csv', parse_dates=['Date'])
rtx = pd.read_csv('RTX.csv', parse_dates=['Date'])
prices = lmt[['Date','close']].rename(columns={'close':'lmt_close'}).merge(
    rtx[['Date','close']].rename(columns={'close':'rtx_close'}), on='Date')
prices = prices.sort_values('Date').reset_index(drop=True)
prices['spread_ratio'] = prices['lmt_close'] / prices['rtx_close']
prices = prices[prices['Date'] >= '2020-01-01']

Executive Summary

This document presents a systematic pairs trading strategy on LMT (Lockheed Martin) vs RTX (Raytheon Technologies). Both are prime defense contractors but have meaningfully different exposure profiles. The strategy predicts 3-day forward returns of the LMT-RTX spread using Bitcoin momentum, calendar seasonality, and TIPS real-rate signals.

This is the most diversifying sleeve in the portfolio: drawdown correlations are negative with 4 of 5 other sleeves, providing genuine hedge behavior.

NoteKey Metrics (2020–2026)
Metric Value
Sharpe Ratio 2.65
Ann. Return 190.8%
Total P&L $19,080 on $10K
Direction Accuracy 59.7%
Max Drawdown -$6,395
Years Profitable 7 / 7
Post-10bps Sharpe 2.26

1. Strategy Overview

1.1 Economic Rationale

LMT and RTX are both prime US defense contractors but their revenue compositions differ substantially. LMT is more heavily weighted toward classified, F-35, and missile-defense programs (long, contracted revenue). RTX has more commercial-aerospace exposure (Pratt & Whitney engines) and defense electronics (Raytheon side). The pair diverges based on:

  1. Bitcoin momentum: BTC 20d momentum is a clean proxy for global liquidity and risk-on positioning. When liquidity expands, RTX’s commercial-aerospace earnings get re-rated upward; when it contracts, LMT’s contracted defense revenue becomes more attractive (defensive bid).

  2. Calendar seasonality: Defense contract awards follow the federal fiscal year (Oct-Sep). End-of-fiscal-year contract bunching and budget reconciliation news create persistent monthly patterns in LMT-RTX returns.

  3. Real rates (TIPS): TIPS daily returns proxy real-rate shocks. Falling real rates favor RTX’s longer-duration commercial cash flows; rising real rates favor LMT’s shorter-duration contracted revenue. The cross-sectional rate sensitivity creates a tradable spread.

  4. 3-day holding period: Defense sector signals propagate slowly through earnings revisions, contract announcements, and budget headlines. Multi-day hold captures the full move while paying transaction costs once.

1.2 Features (4 inputs)

Feature Rationale
spread LMT - RTX daily log return
btc_mom20 Bitcoin 20d cumulative return – global liquidity / risk-on proxy
month_sin sin(2π·month/12) – federal fiscal year seasonality
real_rate_ret TIPS daily return – real rate shock

1.3 Position Sizing and Holding

Enter when the model predicts a 3-day spread move exceeding 0.8%. Hold for 3 trading days. Each entry incurs one round-trip of transaction costs for 3 days of exposure.

2. Performance Analysis

2.1 P&L and Spread

Show code
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 9), sharex=True,
                                     gridspec_kw={'height_ratios': [2, 1.5, 1.5]})

ax1.plot(df['dt'], df['cum_pnl'], color='#1565C0', linewidth=1.5)
ax1.fill_between(df['dt'], 0, df['cum_pnl'], alpha=0.1, color='#1565C0')
ax1.axhline(y=0, color='gray', linewidth=0.5, linestyle='--')
ax1.set_ylabel('Cumulative P&L ($)')
ax1.set_title('Cumulative P&L ($10K Capital)')
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x:,.0f}'))

ax2.plot(prices['Date'], prices['lmt_close'], color='#1565C0', linewidth=1, label='LMT')
ax2.plot(prices['Date'], prices['rtx_close'], color='#E65100', linewidth=1, label='RTX')
ax2.set_ylabel('Price ($)')
ax2.set_title('LMT and RTX Prices')
ax2.legend(loc='upper left', fontsize=9)

ax3.plot(prices['Date'], prices['spread_ratio'], color='#2E7D32', linewidth=1)
ax3.axhline(y=prices['spread_ratio'].mean(), color='gray', linewidth=0.5, linestyle='--',
            label=f'Mean: {prices["spread_ratio"].mean():.3f}')
ax3.set_ylabel('LMT / RTX')
ax3.set_title('Spread Ratio')
ax3.legend(loc='upper left', fontsize=9)

for ax in [ax1, ax2, ax3]:
    for yr in range(df['dt'].dt.year.min(), df['dt'].dt.year.max() + 2):
        ax.axvline(x=pd.Timestamp(f'{yr}-01-01'), color='gray', linewidth=0.3, linestyle=':')

ax3.set_xlim(df['dt'].min(), df['dt'].max())
plt.show()
Figure 1: Cumulative P&L (top), LMT and RTX prices (middle), and spread ratio (bottom)

2.2 Drawdown

Show code
fig, ax = plt.subplots(figsize=(10, 4), constrained_layout=True)
ax.fill_between(df['dt'], df['drawdown'], 0, color='#E53935', alpha=0.4)
ax.set_ylabel('Drawdown ($)')
ax.set_title(f'Drawdown — Max: ${df["drawdown"].min():,.0f}')
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x:,.0f}'))
ax.set_xlim(df['dt'].min(), df['dt'].max())
plt.show()
Figure 2: Underwater equity curve

2.3 Yearly Performance

Show code
df2020 = df[df['dt'] >= '2020-01-01']

yearly = df2020.groupby('year').agg(
    traded=('active', 'sum'),
    pnl=(ret_col, lambda x: (x * capital).sum()),
    ret_mean=(ret_col, lambda x: x[x != 0].mean() if (x != 0).any() else 0),
    ret_std=(ret_col, lambda x: x[x != 0].std() if (x != 0).sum() > 1 else 1),
).reset_index()
yearly['sharpe'] = yearly['ret_mean'] / yearly['ret_std'] * np.sqrt(252)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)

colors = ['#E53935' if p < 0 else '#43A047' for p in yearly['pnl']]
ax1.bar(yearly['year'], yearly['pnl'], color=colors, alpha=0.7)
ax1.axhline(y=0, color='gray', linewidth=0.5)
ax1.set_title('Yearly P&L')
ax1.set_ylabel('P&L ($)')
ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'${x:,.0f}'))

colors_s = ['#E53935' if s < 0 else '#43A047' for s in yearly['sharpe']]
ax2.bar(yearly['year'], yearly['sharpe'], color=colors_s, alpha=0.7)
ax2.axhline(y=0, color='gray', linewidth=0.5)
ax2.axhline(y=1, color='green', linewidth=0.5, linestyle='--', alpha=0.5)
ax2.set_title('Yearly Sharpe Ratio')
ax2.set_ylabel('Sharpe')

plt.show()
Figure 3: Yearly P&L and Sharpe ratios – profitable in every year tested

2.4 Monthly Returns Heatmap

Show code
df2020 = df[df['dt'] >= '2020-01-01'].copy()
df2020['month'] = df2020['dt'].dt.month
df2020['yr'] = df2020['dt'].dt.year
monthly = df2020.groupby(['yr', 'month']).agg(pnl=(ret_col, lambda x: (x * capital).sum())).reset_index()
pivot = monthly.pivot(index='yr', columns='month', values='pnl').fillna(0)

fig, ax = plt.subplots(figsize=(10, 4), constrained_layout=True)
im = ax.imshow(pivot.values, cmap='RdYlGn', aspect='auto', vmin=-1500, vmax=1500)
ax.set_xticks(range(12))
ax.set_xticklabels(['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'])
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index)
ax.set_title('Monthly P&L Heatmap')

for i in range(len(pivot.index)):
    for j in range(12):
        val = pivot.values[i, j]
        if abs(val) > 10:
            color = 'white' if abs(val) > 700 else 'black'
            ax.text(j, i, f'${val:.0f}', ha='center', va='center', fontsize=8, color=color)

plt.colorbar(im, ax=ax, label='P&L ($)', shrink=0.8)
plt.show()
Figure 4: Monthly P&L heatmap (2020–2026)

3. Risk Analysis

3.1 Return Distribution

Show code
traded = df2020[df2020['active'] == 1]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)

rets = traded[ret_col] * 100
ax1.hist(rets, bins=50, color='#1565C0', alpha=0.7, edgecolor='white', linewidth=0.3)
ax1.axvline(x=rets.mean(), color='red', linewidth=1, linestyle='--', label=f'Mean: {rets.mean():.3f}%')
ax1.axvline(x=0, color='gray', linewidth=0.5)
ax1.set_title('Return Distribution (3-day holds)')
ax1.set_xlabel('Return (%)')
ax1.legend()

from scipy import stats
stats.probplot(rets.dropna(), dist="norm", plot=ax2)
ax2.set_title('Q-Q Plot vs Normal')
ax2.get_lines()[0].set_markerfacecolor('#1565C0')
ax2.get_lines()[0].set_markersize(3)

plt.show()
Figure 5: Return distribution (3-day holds)

3.2 Rolling Metrics

Show code
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), constrained_layout=True, sharex=True)

roll_mean = df2020[ret_col].rolling(63).apply(lambda x: x[x!=0].mean() if (x!=0).any() else 0)
roll_std = df2020[ret_col].rolling(63).apply(lambda x: x[x!=0].std() if (x!=0).sum() > 5 else np.nan)
rolling_sharpe = roll_mean / roll_std * np.sqrt(252)

ax1.plot(df2020['dt'], rolling_sharpe, color='#43A047', linewidth=1)
ax1.axhline(y=0, color='gray', linewidth=0.5, linestyle='--')
ax1.axhline(y=1, color='green', linewidth=0.5, linestyle='--', alpha=0.5)
ax1.set_title('Rolling 63-day Sharpe Ratio')
ax1.set_ylabel('Sharpe')
ax1.set_ylim(-10, 20)

df2020_copy = df2020.copy()
df2020_copy['correct'] = (df2020_copy['active'] == 1) & (np.sign(df2020_copy['pred']) == np.sign(df2020_copy['spread_ret']))
rolling_acc = df2020_copy['correct'].rolling(63).mean() * 100
ax2.plot(df2020['dt'], rolling_acc, color='#FF8F00', linewidth=1)
ax2.axhline(y=50, color='gray', linewidth=0.5, linestyle='--')
ax2.set_title('Rolling 63-day Direction Accuracy')
ax2.set_ylabel('Accuracy (%)')
ax2.set_xlim(df2020['dt'].min(), df2020['dt'].max())

plt.show()
Figure 6: Rolling Sharpe and accuracy

4. Detailed Statistics

Show code
traded = df2020[df2020['active'] == 1]
total_pnl = (df2020[ret_col] * capital).sum()
sharpe = traded[ret_col].mean() / traded[ret_col].std() * np.sqrt(252)
downside = traded.loc[traded[ret_col] < 0, ret_col]
sortino = traded[ret_col].mean() / np.sqrt((downside**2).mean()) * np.sqrt(252)
max_dd = df2020['drawdown'].min()
wins = traded[traded[ret_col] > 0][ret_col]
losses = traded[traded[ret_col] < 0][ret_col]

stats_dict = {
    'Period': f'{df2020["dt"].min().strftime("%Y-%m-%d")} to {df2020["dt"].max().strftime("%Y-%m-%d")}',
    'Traded Periods': len(traded),
    'Total P&L': f'${total_pnl:,.0f}',
    'Sharpe Ratio': f'{sharpe:.2f}',
    'Sortino Ratio': f'{sortino:.2f}',
    'Max Drawdown': f'${max_dd:,.0f}',
    'MAR Ratio': f'{traded[ret_col].mean() * 252 / abs(max_dd / capital):.2f}',
    'Direction Accuracy': f'{(np.sign(traded["pred"]) == np.sign(traded["spread_ret"])).mean()*100:.1f}%',
    'Win/Loss Ratio': f'{abs(wins.mean()/losses.mean()):.2f}',
    'Holding Period': '3 trading days',
    'p/n Ratio': '0.02 (4 dims / 199 samples)',
}

pd.DataFrame(list(stats_dict.items()), columns=['Metric', 'Value']).style.hide(axis='index')
Table 1
Metric Value
Period 2020-01-02 to 2026-04-08
Traded Periods 283
Total P&L $19,080
Sharpe Ratio 2.65
Sortino Ratio 3.06
Max Drawdown $-6,395
MAR Ratio 2.66
Direction Accuracy 59.7%
Win/Loss Ratio 1.20
Holding Period 3 trading days
p/n Ratio 0.02 (4 dims / 199 samples)
Show code
yearly_data = []
for yr in sorted(df2020['year'].unique()):
    ydf = df2020[df2020['year'] == yr]
    yt = ydf[ydf['active'] == 1]
    if len(yt) == 0:
        continue
    pnl = (ydf[ret_col] * capital).sum()
    s = yt[ret_col].mean() / yt[ret_col].std() * np.sqrt(252) if yt[ret_col].std() > 0 else 0
    ds = yt.loc[yt[ret_col] < 0, ret_col]
    so = yt[ret_col].mean() / np.sqrt((ds**2).mean()) * np.sqrt(252) if len(ds) > 0 else 0
    acc = (np.sign(yt['pred']) == np.sign(yt['spread_ret'])).mean() * 100
    yearly_data.append({
        'Year': yr, 'Traded': len(yt), 'Sat Out': len(ydf) - len(yt),
        'Accuracy': f'{acc:.1f}%', 'P&L': f'${pnl:,.0f}',
        'Sharpe': f'{s:.2f}', 'Sortino': f'{so:.2f}'
    })

pd.DataFrame(yearly_data).style.hide(axis='index')
Table 2
Year Traded Sat Out Accuracy P&L Sharpe Sortino
2020 53 58 52.8% $4,444 1.83 2.14
2021 58 54 60.3% $1,964 3.13 3.26
2022 43 67 51.2% $547 0.70 0.74
2023 32 75 59.4% $784 2.03 1.90
2024 40 72 62.5% $2,185 4.87 6.14
2025 44 66 65.9% $6,819 5.23 8.88
2026 13 17 84.6% $2,337 11.57 16.72

5. Strategy Construction

5.1 Model Architecture

5.2 Why This Pair Works

LMT and RTX are nominally substitutes (both prime defense contractors), so naive cointegration would suggest they should track each other. The signal lives in their differential exposure to liquidity, fiscal calendar, and real rates:

  • Liquidity (BTC momentum): Bitcoin captures speculative-leverage cycles. Defense contractor multiples expand and contract with these cycles, but RTX (more commercial-aerospace) responds more elastically than LMT (more contracted defense).

  • Fiscal seasonality: Federal contract awards spike at fiscal year-end (Sept) and quarterly milestones. The mix of awards (LMT-heavy classified vs RTX-heavy diversified) creates periodic divergence.

  • Real rates: Defense contracts are long-duration cash flows. Real rate shocks differentially affect contracted (LMT) vs commercial (RTX) revenue streams.

The 3-day hold lets these slow-moving signals fully transmit. Daily trading underperforms (Sharpe 1.55 gross, 1.08 net) because the noise overwhelms the signal at one-day horizon.

5.3 Model Code

class Aggregate:
    @staticmethod
    def finalize(table, params):
        if table.num_rows < 2:
            return None
        data = table.to_pandas().values.astype(np.float64)
        n, nc = data.shape
        seed = int(params.get('seed', 42))
        conf_thresh = params.get('conf', 0.60)
        min_move = params.get('min_move', 0.008)
        fc = int(params.get('fwd_col', nc - 1))  # last col = target
        hold = int(params.get('hold', 3))

        if n < 10 + hold:
            return None

        X = data[:-(hold), :fc]   # features
        y_ret = data[hold:, fc]   # 3-day forward spread return

        if np.any(np.isnan(X)) or np.any(np.isnan(y_ret)):
            return 0.0

        y_dir = (y_ret > 0).astype(int)
        last = data[-1:, :fc]

        from sklearn.linear_model import LogisticRegression, Ridge
        from sklearn.pipeline import make_pipeline
        from sklearn.preprocessing import StandardScaler

        if len(set(y_dir)) < 2:
            return 0.0

        clf = make_pipeline(
            StandardScaler(),
            LogisticRegression(C=0.1, max_iter=1000, random_state=seed)
        )
        clf.fit(X, y_dir)
        prob_up = clf.predict_proba(last)[0][1]

        reg = make_pipeline(StandardScaler(), Ridge(alpha=1.0))
        reg.fit(X, y_ret)
        pred_mag = abs(float(reg.predict(last)[0]))

        if pred_mag < min_move:
            return 0.0

        if prob_up > conf_thresh:
            return pred_mag
        elif prob_up < (1.0 - conf_thresh):
            return -pred_mag
        else:
            return 0.0

6. Portfolio Role: Hedge Sleeve

This is the most diversifying sleeve in the portfolio. Drawdown correlations with the other 5 sleeves:

Pair Drawdown Corr
vs XME/DBB (metals) -0.30
vs GDX/GLD (gold) -0.04
vs XLE/USO (energy) +0.14
vs EFA/SPY (intl equity) -0.19
vs XLF/XLY (sector rotation) -0.24

The negative correlations are the key feature: when commodity and sector-rotation strategies are underwater, LMT/RTX is typically making money. This is genuine portfolio insurance, not just diversification by chance.

The trade-off: LMT/RTX has the largest absolute drawdown (-$6,395) of any sleeve. Higher single-strategy variance buys the negative correlation.

7. Limitations and Risks

  1. Largest drawdown of any sleeve ($6,395). Volatility is the price of negative correlation. Don’t run this sleeve at higher leverage than the others without resizing.

  2. 283 trades over 6.3 years (~45/year). Statistical confidence on 59.7% accuracy with 283 trades has a 95% CI of roughly 54-65%.

  3. 2022 was a near-miss year (only $547 PnL). The model can have lean years; smaller drawdowns are tolerable when other sleeves are paying.

  4. Bitcoin as a feature: BTC’s evolving relationship with liquidity (post-2024 ETF approval, post-halving cycle) may shift. Re-validate this signal periodically.

  5. Defense industry consolidation: M&A or major program cancellations (e.g., F-35 changes) could permanently shift the LMT-RTX relationship.

  6. Seed sensitivity: Zero – deterministic.

8. Reproducibility

bash scripts/run_backtest.sh
bash tests/test_backtest.sh

Parameters

Parameter Value
Training window 200 days
Confidence threshold 0.60
Min predicted move 0.008 (0.8% over 3 days)
Holding period 3 trading days
Position sizing Binary (100%)
Gates None
LogReg C 0.1
Ridge alpha 1.0

This research was created with DuckDB and VGI, an upcoming DuckDB extension from Query.Farm that allows custom aggregate functions to be written in any language with an Apache Arrow implementation.