Files
investor-flow/app/server/src/derisking/DeriskingEngine.ts
T

191 lines
7.5 KiB
TypeScript
Raw Normal View History

// Investor Flow — Derisking Strategy Library (Slice 23)
//
// ADR-0007: "winners have flexibility; losers only cut, never average down."
// Derisking suggestions are considerations, not instructions. No trade verbs.
//
// Pure/cache-deterministic: no I/O. Pure logic operating on position + market data.
// ─── Derisking Strategy Types ───────────────────────────────────────────────
export type DeriskingStrategyId =
| 'scale_out_at_targets'
| 'stop_trail_ema21_50'
| 'thesis_based_partial'
| 'option_protected_collar'
| 'regime_cut'
| 'correlation_driven';
export interface DeriskingStrategy {
id: DeriskingStrategyId;
name: string;
description: string;
alfredFraming: string;
requiresOptionsSleeve: boolean;
}
export const DERISKING_STRATEGIES: DeriskingStrategy[] = [
{
id: 'scale_out_at_targets',
name: 'Scale Out at Targets',
description: 'Reduce partial position size as price reaches predefined profit targets. This adds flexibility — some position remains if the thesis continues to play out.',
alfredFraming: 'Winners have flexibility. Scaling out at targets locks in some gains while leaving room for further upside.',
requiresOptionsSleeve: false,
},
{
id: 'stop_trail_ema21_50',
name: 'Stop-Trail EMA 21/50',
description: 'Trail a stop above the 21-day or 50-day EMA. This adapts the stop to volatility, allowing the position to breathe while protecting gains.',
alfredFraming: 'Trailing stops adapt to changing conditions. A static stop ignores new information.',
requiresOptionsSleeve: false,
},
{
id: 'thesis_based_partial',
name: 'Thesis-Based Partial Reduction',
description: 'If the thesis is weakening but not fully invalidated, consider reducing position size rather than fully exiting. This acknowledges uncertainty without abandoning the thesis entirely.',
alfredFraming: 'When the thesis weakens but is not broken, flexibility means adjusting size — not all or nothing.',
requiresOptionsSleeve: false,
},
{
id: 'option_protected_collar',
name: 'Option-Protected Collar',
description: 'Buy a protective put and sell a covered call to collar the position. This defines downside risk while partially financing the protection with call premium.',
alfredFraming: 'A collar is insurance financed by someone else. You give up some upside to protect the downside.',
requiresOptionsSleeve: true,
},
{
id: 'regime_cut',
name: 'Regime-Cut Reduction',
description: 'When the market regime shifts to trending-down, consider reducing exposure across the portfolio. This is a macro-level derisking response to changed conditions.',
alfredFraming: 'When the tide turns, reduce exposure. Fighting a regime shift is a common way winners become losers.',
requiresOptionsSleeve: false,
},
{
id: 'correlation_driven',
name: 'Correlation-Driven Reduction',
description: 'When multiple positions become highly correlated, consider reducing the most correlated ones. Concentrated correlation creates hidden portfolio risk.',
alfredFraming: 'When everything moves together, you do not have diversification — you have one bet wearing many masks.',
requiresOptionsSleeve: false,
},
];
// ─── Derisking Suggestion Input ─────────────────────────────────────────────
export interface DeriskingInput {
symbol: string;
currentPrice: number;
avgCost: number;
shares: number;
ema21?: number;
ema50?: number;
thesisStatus?: 'intact' | 'weakening' | 'broken';
currentRegime?: 'trending-up' | 'trending-down' | 'range-bound';
portfolioCorrelation?: number; // 0-1, how correlated this position is with others
optionsUnlockState?: number; // 0-4, from ConvexityGate
profitTargets?: number[]; // price levels
}
export interface DeriskingSuggestion {
strategy: DeriskingStrategy;
applicable: boolean;
reason: string;
details: string;
}
// ─── Suggestion Engine ──────────────────────────────────────────────────────
export function suggestDerisking(input: DeriskingInput): DeriskingSuggestion[] {
const suggestions: DeriskingSuggestion[] = [];
const inProfit = input.currentPrice > input.avgCost;
for (const strat of DERISKING_STRATEGIES) {
const suggestion = evaluateStrategy(strat, input, inProfit);
if (suggestion.applicable) {
suggestions.push(suggestion);
}
}
return suggestions;
}
function evaluateStrategy(
strat: DeriskingStrategy,
input: DeriskingInput,
inProfit: boolean,
): DeriskingSuggestion {
switch (strat.id) {
case 'scale_out_at_targets': {
const targets = input.profitTargets ?? [];
const hittingTarget = targets.some((t) => input.currentPrice >= t);
return {
strategy: strat,
applicable: inProfit && hittingTarget,
reason: hittingTarget
? `Price has reached a predefined target. Consider scaling out a portion.`
: 'Price has not reached any predefined target.',
details: strat.alfredFraming,
};
}
case 'stop_trail_ema21_50': {
const hasEMA = input.ema21 !== undefined || input.ema50 !== undefined;
const belowEMA = input.ema21 !== undefined && input.currentPrice < input.ema21;
return {
strategy: strat,
applicable: hasEMA && inProfit,
reason: belowEMA
? `Price has fallen below EMA21. A trailing stop may help protect gains.`
: 'Consider trailing a stop above EMA21 or EMA50 to protect gains while allowing the position to breathe.',
details: strat.alfredFraming,
};
}
case 'thesis_based_partial': {
const weakening = input.thesisStatus === 'weakening';
return {
strategy: strat,
applicable: weakening,
reason: weakening
? 'Thesis is weakening but not broken. Consider a partial reduction rather than a full exit.'
: 'Thesis is not weakening.',
details: strat.alfredFraming,
};
}
case 'option_protected_collar': {
const hasOptionsSleeve = (input.optionsUnlockState ?? 0) >= 3;
return {
strategy: strat,
applicable: hasOptionsSleeve && inProfit,
reason: hasOptionsSleeve
? 'Options insurance sleeve is unlocked. A collar may define downside risk while partially financing protection.'
: 'Options insurance sleeve is not unlocked. Collar strategy requires the insurance sleeve tier.',
details: strat.alfredFraming,
};
}
case 'regime_cut': {
const regimeDown = input.currentRegime === 'trending-down';
return {
strategy: strat,
applicable: regimeDown,
reason: regimeDown
? 'Market regime has shifted to trending-down. Consider reducing exposure across the portfolio.'
: 'Market regime is not trending-down.',
details: strat.alfredFraming,
};
}
case 'correlation_driven': {
const highCorr = (input.portfolioCorrelation ?? 0) > 0.7;
return {
strategy: strat,
applicable: highCorr,
reason: highCorr
? `Position correlation is ${(input.portfolioCorrelation! * 100).toFixed(0)}%. Consider reducing the most correlated positions.`
: 'Position correlation is within acceptable range.',
details: strat.alfredFraming,
};
}
}
}