/**
* Unusual options activity detection
*
* Identifies anomalous trading patterns that may indicate informed trading.
*/
import type { OptionsChain, OptionContract, UnusualActivity, UnusualActivityReport } from "./types";
export interface DetectUnusualActivityParams {
/** Options chain data */
chain: OptionsChain;
/** Minimum volume to consider (default: 100) */
minVolume?: number;
/** Volume/OI ratio threshold for spike detection (default: 0.5) */
volumeOiThreshold?: number;
/** Percentile threshold for "unusual" (default: 90) */
percentileThreshold?: number;
/** Maximum number of results to return (default: 20) */
maxResults?: number;
}
/**
* Detect unusual options activity in an options chain
*/
export function detectUnusualActivity(
params: DetectUnusualActivityParams
): UnusualActivityReport {
const {
chain,
minVolume = 100,
volumeOiThreshold = 0.5,
percentileThreshold = 90,
maxResults = 20,
} = params;
const activities: UnusualActivity[] = [];
const spot = chain.underlyingPrice;
// Calculate volume statistics for percentile-based detection
const volumes = chain.contracts
.filter((c) => c.volume > 0)
.map((c) => c.volume);
if (volumes.length === 0) {
return createEmptyReport(chain);
}
const volumeThreshold = percentileValue(volumes, percentileThreshold);
// Analyze each contract
for (const contract of chain.contracts) {
if (contract.volume < minVolume) continue;
const volumeToOiRatio =
contract.openInterest > 0 ? contract.volume / contract.openInterest : contract.volume;
const isWing = isWingStrike(contract, spot);
const moneyness = contract.strike / spot;
// Check for various unusual activity patterns
const detectedActivities: Omit<UnusualActivity, "contract">[] = [];
// 1. Volume spike (high absolute volume)
if (contract.volume >= volumeThreshold) {
detectedActivities.push({
type: "volume_spike",
score: Math.min(100, Math.round((contract.volume / volumeThreshold) * 50)),
description: `Volume ${contract.volume.toLocaleString()} exceeds ${percentileThreshold}th percentile`,
volumeToOiRatio,
sentiment: inferSentiment(contract, spot),
});
}
// 2. High volume to OI ratio (potential opening positions)
if (volumeToOiRatio >= volumeOiThreshold && contract.volume >= minVolume) {
detectedActivities.push({
type: "oi_change",
score: Math.min(100, Math.round(volumeToOiRatio * 40)),
description: `Vol/OI ratio ${volumeToOiRatio.toFixed(2)} suggests new position opening`,
volumeToOiRatio,
sentiment: inferSentiment(contract, spot),
});
}
// 3. Wing activity (OTM options with high volume)
if (isWing && contract.volume >= volumeThreshold * 0.5) {
const wingDesc =
moneyness < 0.9
? "deep OTM put"
: moneyness > 1.1
? "deep OTM call"
: "wing";
detectedActivities.push({
type: "wing_activity",
score: Math.min(100, Math.round((contract.volume / volumeThreshold) * 60)),
description: `Unusual ${wingDesc} activity at ${(moneyness * 100).toFixed(0)}% moneyness`,
volumeToOiRatio,
sentiment: inferSentiment(contract, spot),
});
}
// 4. Large single trades (volume >> average)
if (contract.volume >= volumeThreshold * 2) {
detectedActivities.push({
type: "large_trade",
score: Math.min(100, Math.round((contract.volume / volumeThreshold) * 30)),
description: `Large trade: ${contract.volume.toLocaleString()} contracts`,
volumeToOiRatio,
sentiment: inferSentiment(contract, spot),
});
}
// Add the highest-scoring activity for this contract
if (detectedActivities.length > 0) {
const best = detectedActivities.sort((a, b) => b.score - a.score)[0];
activities.push({
contract,
...best,
});
}
}
// Sort by score and limit results
activities.sort((a, b) => b.score - a.score);
const topActivities = activities.slice(0, maxResults);
// Calculate summary stats
const callActivities = topActivities.filter((a) => a.contract.right === "call");
const putActivities = topActivities.filter((a) => a.contract.right === "put");
const bullishCount = topActivities.filter((a) => a.sentiment === "bullish").length;
const bearishCount = topActivities.filter((a) => a.sentiment === "bearish").length;
let dominantSentiment: "bullish" | "bearish" | "mixed" | "neutral" = "neutral";
if (bullishCount > bearishCount * 1.5) {
dominantSentiment = "bullish";
} else if (bearishCount > bullishCount * 1.5) {
dominantSentiment = "bearish";
} else if (bullishCount > 0 || bearishCount > 0) {
dominantSentiment = "mixed";
}
// Get top strikes by activity
const strikeCounts = new Map<number, number>();
for (const a of topActivities) {
const count = strikeCounts.get(a.contract.strike) || 0;
strikeCounts.set(a.contract.strike, count + 1);
}
const topStrikes = [...strikeCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([strike]) => strike);
return {
symbol: chain.symbol,
asOf: chain.asOf,
spot,
activities: topActivities,
summary: {
totalUnusualContracts: topActivities.length,
callVsPutRatio:
putActivities.length > 0
? callActivities.length / putActivities.length
: callActivities.length > 0
? Infinity
: 0,
dominantSentiment,
topStrikes,
},
};
}
/**
* Check if a strike is a "wing" (far OTM)
*/
function isWingStrike(contract: OptionContract, spot: number): boolean {
const moneyness = contract.strike / spot;
if (contract.right === "call") {
return moneyness > 1.1; // 10%+ OTM call
} else {
return moneyness < 0.9; // 10%+ OTM put
}
}
/**
* Infer sentiment from option activity
*/
function inferSentiment(
contract: OptionContract,
spot: number
): "bullish" | "bearish" | "neutral" {
const moneyness = contract.strike / spot;
if (contract.right === "call") {
// Call buying is generally bullish
// But deep ITM calls might be hedging (neutral)
if (moneyness < 0.95) return "neutral"; // Deep ITM
return "bullish";
} else {
// Put buying is generally bearish
// But deep ITM puts might be hedging (neutral)
if (moneyness > 1.05) return "neutral"; // Deep ITM
return "bearish";
}
}
/**
* Calculate percentile value from array
*/
function percentileValue(arr: number[], p: number): number {
const sorted = [...arr].sort((a, b) => a - b);
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, idx)];
}
/**
* Create empty report when no data available
*/
function createEmptyReport(chain: OptionsChain): UnusualActivityReport {
return {
symbol: chain.symbol,
asOf: chain.asOf,
spot: chain.underlyingPrice,
activities: [],
summary: {
totalUnusualContracts: 0,
callVsPutRatio: 0,
dominantSentiment: "neutral",
topStrikes: [],
},
};
}