[SPRINT-2] feat: Add AMD zones overlay and enhance screener

ST-003.2: ML Overlay (13 SP)
- Add AMDZonesOverlay component for accumulation/manipulation/distribution visualization
- AMD zones rendered as colored shaded areas on chart
- Prediction lines and signal markers already implemented

ST-003.6: Screener Advanced (8 SP)
- Add volumeRatio, trend, mlSignal, mlConfidence columns
- Add 4 new filter presets: Bullish/Bearish Trend, Change >2%, ML Buy Signal
- Vol Ratio column with color coding (>2x amber, >1x green)
- ML Signal badge with confidence percentage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 00:06:25 -06:00
parent a3bd7af7b7
commit 950d0a7804
6 changed files with 446 additions and 5 deletions

View File

@ -19,8 +19,8 @@ import {
SeriesMarker,
} from 'lightweight-charts';
import type { Candle, CandlestickChartProps } from '../../../types/trading.types';
import type { MLSignal, RangePrediction, AMDPhase } from '../../../services/mlService';
import { getLatestSignal, getRangePrediction, getAMDPhase } from '../../../services/mlService';
import type { MLSignal, RangePrediction, AMDPhase, AMDZone } from '../../../services/mlService';
import { getLatestSignal, getRangePrediction, getAMDPhase, getAMDZones } from '../../../services/mlService';
// ============================================================================
// Types
@ -51,6 +51,7 @@ interface MLOverlays {
signal?: MLSignal | null;
rangePrediction?: RangePrediction | null;
amdPhase?: AMDPhase | null;
amdZones?: AMDZone[];
orderBlocks?: OrderBlock[];
fairValueGaps?: FairValueGap[];
}
@ -62,6 +63,7 @@ interface CandlestickChartWithMLProps extends CandlestickChartProps {
showOrderBlocks?: boolean;
showFairValueGaps?: boolean;
showAMDPhase?: boolean;
showAMDZones?: boolean;
autoRefreshML?: boolean;
refreshInterval?: number;
}
@ -146,6 +148,7 @@ export const CandlestickChartWithML: React.FC<CandlestickChartWithMLProps> = ({
showOrderBlocks = false,
showFairValueGaps = false,
showAMDPhase = true,
showAMDZones = true,
autoRefreshML = true,
refreshInterval = 30000,
}) => {
@ -157,6 +160,7 @@ export const CandlestickChartWithML: React.FC<CandlestickChartWithMLProps> = ({
const rangeLowSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const priceLinesRef = useRef<ReturnType<ISeriesApi<'Candlestick'>['createPriceLine']>[]>([]);
const amdZoneSeriesRef = useRef<Map<string, ISeriesApi<'Area'>>>(new Map());
const [mlOverlays, setMlOverlays] = useState<MLOverlays>({});
const [isLoadingML, setIsLoadingML] = useState(false);
@ -169,23 +173,25 @@ export const CandlestickChartWithML: React.FC<CandlestickChartWithMLProps> = ({
setIsLoadingML(true);
try {
const [signal, rangePred, amd] = await Promise.all([
const [signal, rangePred, amd, zones] = await Promise.all([
showSignalLevels ? getLatestSignal(symbol) : Promise.resolve(null),
showRangePrediction ? getRangePrediction(symbol, interval) : Promise.resolve(null),
showAMDPhase ? getAMDPhase(symbol) : Promise.resolve(null),
showAMDZones ? getAMDZones(symbol, interval, 10) : Promise.resolve([]),
]);
setMlOverlays({
signal,
rangePrediction: rangePred,
amdPhase: amd,
amdZones: zones,
});
} catch (error) {
console.error('Error fetching ML data:', error);
} finally {
setIsLoadingML(false);
}
}, [symbol, interval, enableMLOverlays, showSignalLevels, showRangePrediction, showAMDPhase]);
}, [symbol, interval, enableMLOverlays, showSignalLevels, showRangePrediction, showAMDPhase, showAMDZones]);
// Auto-refresh ML data
useEffect(() => {
@ -415,6 +421,84 @@ export const CandlestickChartWithML: React.FC<CandlestickChartWithMLProps> = ({
}
}, [mlOverlays, showSignalLevels, showRangePrediction]);
// Update AMD zones visualization
useEffect(() => {
if (!chartRef.current || !showAMDZones) return;
const chart = chartRef.current;
// Clear existing AMD zone series
amdZoneSeriesRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
amdZoneSeriesRef.current.clear();
// Render new AMD zones
const zones = mlOverlays.amdZones || [];
if (zones.length === 0) return;
zones.forEach((zone, index) => {
const zoneId = `amd-zone-${index}-${zone.phase}`;
// Get color based on phase
let zoneColor: string;
switch (zone.phase) {
case 'accumulation':
zoneColor = ML_COLORS.amdAccumulation;
break;
case 'manipulation':
zoneColor = ML_COLORS.amdManipulation;
break;
case 'distribution':
zoneColor = ML_COLORS.amdDistribution;
break;
default:
zoneColor = 'rgba(107, 114, 128, 0.1)';
}
// Adjust opacity based on confidence
const opacity = Math.max(0.05, 0.15 * zone.confidence);
const adjustedColor = zoneColor.replace(/[\d.]+\)$/, `${opacity})`);
// Create area series for zone
const areaSeries = chart.addAreaSeries({
topColor: adjustedColor,
bottomColor: 'transparent',
lineColor: 'transparent',
lineWidth: 1,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
// Set zone data
const startTime = (zone.startTime / 1000) as Time;
const endTime = (zone.endTime / 1000) as Time;
areaSeries.setData([
{ time: startTime, value: zone.priceHigh },
{ time: endTime, value: zone.priceHigh },
]);
amdZoneSeriesRef.current.set(zoneId, areaSeries);
});
return () => {
amdZoneSeriesRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
amdZoneSeriesRef.current.clear();
};
}, [mlOverlays.amdZones, showAMDZones]);
// Update data method
const updateData = useCallback(
(candles: Candle[]) => {

View File

@ -40,6 +40,7 @@ export interface ScreenerResult {
low52w: number;
rsi?: number;
macdSignal?: 'bullish' | 'bearish' | 'neutral';
trend?: 'bullish' | 'bearish' | 'sideways';
sma20?: number;
sma50?: number;
sma200?: number;
@ -47,6 +48,9 @@ export interface ScreenerResult {
beta?: number;
dividendYield?: number;
isFavorite?: boolean;
volumeRatio?: number;
mlSignal?: 'buy' | 'sell' | 'hold' | null;
mlConfidence?: number;
}
export interface ScreenerFilter {
@ -111,12 +115,29 @@ const FILTER_PRESETS: { name: string; filters: Omit<ScreenerFilter, 'id'>[] }[]
name: 'Near 52W High',
filters: [{ field: 'changePercent', operator: 'gt', value: 5, enabled: true }],
},
{
name: 'Bullish Trend',
filters: [{ field: 'trend', operator: 'eq', value: 'bullish', enabled: true }],
},
{
name: 'Bearish Trend',
filters: [{ field: 'trend', operator: 'eq', value: 'bearish', enabled: true }],
},
{
name: 'Change > 2%',
filters: [{ field: 'changePercent', operator: 'gt', value: 2, enabled: true }],
},
{
name: 'ML Buy Signal',
filters: [{ field: 'mlSignal', operator: 'eq', value: 'buy', enabled: true }],
},
];
const FILTERABLE_FIELDS: { field: keyof ScreenerResult; label: string; type: 'number' | 'string' }[] = [
{ field: 'price', label: 'Price', type: 'number' },
{ field: 'changePercent', label: 'Change %', type: 'number' },
{ field: 'volume', label: 'Volume', type: 'number' },
{ field: 'volumeRatio', label: 'Vol Ratio', type: 'number' },
{ field: 'marketCap', label: 'Market Cap', type: 'number' },
{ field: 'pe', label: 'P/E Ratio', type: 'number' },
{ field: 'rsi', label: 'RSI', type: 'number' },
@ -124,6 +145,9 @@ const FILTERABLE_FIELDS: { field: keyof ScreenerResult; label: string; type: 'nu
{ field: 'dividendYield', label: 'Dividend Yield', type: 'number' },
{ field: 'sector', label: 'Sector', type: 'string' },
{ field: 'exchange', label: 'Exchange', type: 'string' },
{ field: 'trend', label: 'Trend', type: 'string' },
{ field: 'mlSignal', label: 'ML Signal', type: 'string' },
{ field: 'mlConfidence', label: 'ML Confidence', type: 'number' },
];
const TradingScreener: React.FC<TradingScreenerProps> = ({
@ -152,7 +176,11 @@ const TradingScreener: React.FC<TradingScreenerProps> = ({
// Apply filters and sorting
const filteredResults = useMemo(() => {
let filtered = [...results];
// Enrich results with computed fields if not present
let filtered = results.map((r) => ({
...r,
volumeRatio: r.volumeRatio ?? (r.avgVolume > 0 ? r.volume / r.avgVolume : undefined),
}));
// Search filter
if (searchQuery) {
@ -529,6 +557,16 @@ const TradingScreener: React.FC<TradingScreenerProps> = ({
RSI{renderSortIndicator('rsi')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium hidden xl:table-cell">
<button onClick={() => handleSort('volumeRatio')} className="hover:text-white">
Vol Ratio{renderSortIndicator('volumeRatio')}
</button>
</th>
<th className="text-center py-2 px-2 text-gray-400 font-medium hidden xl:table-cell">
<button onClick={() => handleSort('mlSignal')} className="hover:text-white">
ML Signal{renderSortIndicator('mlSignal')}
</button>
</th>
<th className="text-center py-2 px-2 text-gray-400 font-medium w-20">Actions</th>
</tr>
</thead>
@ -581,6 +619,37 @@ const TradingScreener: React.FC<TradingScreenerProps> = ({
</span>
)}
</td>
<td className="py-2 px-2 text-right hidden xl:table-cell">
{result.volumeRatio !== undefined && (
<span
className={`font-mono ${
result.volumeRatio > 2 ? 'text-amber-400' : result.volumeRatio > 1 ? 'text-green-400' : 'text-gray-300'
}`}
>
{result.volumeRatio.toFixed(2)}x
</span>
)}
</td>
<td className="py-2 px-2 text-center hidden xl:table-cell">
{result.mlSignal && (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
result.mlSignal === 'buy'
? 'bg-green-500/20 text-green-400'
: result.mlSignal === 'sell'
? 'bg-red-500/20 text-red-400'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{result.mlSignal.toUpperCase()}
{result.mlConfidence !== undefined && (
<span className="text-[8px] opacity-75">
{(result.mlConfidence * 100).toFixed(0)}%
</span>
)}
</span>
)}
</td>
<td className="py-2 px-2">
<div className="flex items-center justify-center gap-1">
<button

View File

@ -0,0 +1,226 @@
/**
* AMDZonesOverlay Component
* Renders AMD (Accumulation, Manipulation, Distribution) zones as shaded areas on the chart
*/
import { useEffect, useRef } from 'react';
import type { IChartApi, ISeriesApi, Time } from 'lightweight-charts';
// ============================================================================
// Types
// ============================================================================
export type AMDPhaseType = 'accumulation' | 'manipulation' | 'distribution' | 'unknown';
export interface AMDZone {
phase: AMDPhaseType;
startTime: number;
endTime: number;
priceHigh: number;
priceLow: number;
confidence: number;
}
export interface AMDZonesOverlayProps {
chartRef: React.RefObject<IChartApi>;
zones: AMDZone[];
visible?: boolean;
colors?: {
accumulation: string;
manipulation: string;
distribution: string;
unknown: string;
};
}
// ============================================================================
// Default Colors
// ============================================================================
const DEFAULT_AMD_COLORS = {
accumulation: 'rgba(59, 130, 246, 0.15)', // Blue - calm, building
manipulation: 'rgba(249, 115, 22, 0.15)', // Orange - caution, volatility
distribution: 'rgba(239, 68, 68, 0.15)', // Red - selling pressure
unknown: 'rgba(107, 114, 128, 0.1)', // Gray - uncertain
};
// Border colors for zone edges
const BORDER_COLORS: Record<AMDPhaseType, string> = {
accumulation: 'rgba(59, 130, 246, 0.5)',
manipulation: 'rgba(249, 115, 22, 0.5)',
distribution: 'rgba(239, 68, 68, 0.5)',
unknown: 'rgba(107, 114, 128, 0.3)',
};
// ============================================================================
// Component
// ============================================================================
export const AMDZonesOverlay: React.FC<AMDZonesOverlayProps> = ({
chartRef,
zones,
visible = true,
colors = DEFAULT_AMD_COLORS,
}) => {
const seriesMapRef = useRef<Map<string, ISeriesApi<'Area'>>>(new Map());
const lineSeriesMapRef = useRef<Map<string, ISeriesApi<'Line'>>>(new Map());
// Initialize and update zones
useEffect(() => {
if (!chartRef.current || !visible) return;
const chart = chartRef.current;
// Clear existing series
seriesMapRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
seriesMapRef.current.clear();
lineSeriesMapRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
lineSeriesMapRef.current.clear();
if (zones.length === 0) return;
// Create area series for each zone
zones.forEach((zone, index) => {
const zoneId = `amd-zone-${index}-${zone.phase}`;
const zoneColor = colors[zone.phase] || DEFAULT_AMD_COLORS[zone.phase];
const borderColor = BORDER_COLORS[zone.phase];
// Create area series for the zone fill
const areaSeries = chart.addAreaSeries({
topColor: zoneColor,
bottomColor: 'transparent',
lineColor: 'transparent',
lineWidth: 1,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
// Calculate opacity based on confidence
const adjustedColor = adjustColorOpacity(zoneColor, zone.confidence);
areaSeries.applyOptions({
topColor: adjustedColor,
});
// Create data points for the zone area
const startTime = (zone.startTime / 1000) as Time;
const endTime = (zone.endTime / 1000) as Time;
const areaData = [
{ time: startTime, value: zone.priceHigh },
{ time: endTime, value: zone.priceHigh },
];
areaSeries.setData(areaData);
seriesMapRef.current.set(zoneId, areaSeries);
// Create top line for visual boundary
const topLine = chart.addLineSeries({
color: borderColor,
lineWidth: 1,
lineStyle: 2, // Dashed
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
topLine.setData([
{ time: startTime, value: zone.priceHigh },
{ time: endTime, value: zone.priceHigh },
]);
lineSeriesMapRef.current.set(`${zoneId}-top`, topLine);
// Create bottom line for visual boundary
const bottomLine = chart.addLineSeries({
color: borderColor,
lineWidth: 1,
lineStyle: 2, // Dashed
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
bottomLine.setData([
{ time: startTime, value: zone.priceLow },
{ time: endTime, value: zone.priceLow },
]);
lineSeriesMapRef.current.set(`${zoneId}-bottom`, bottomLine);
});
return () => {
// Cleanup on unmount
seriesMapRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
seriesMapRef.current.clear();
lineSeriesMapRef.current.forEach((series) => {
try {
chart.removeSeries(series);
} catch {
// Series might already be removed
}
});
lineSeriesMapRef.current.clear();
};
}, [chartRef, zones, visible, colors]);
// Update visibility
useEffect(() => {
seriesMapRef.current.forEach((series) => {
series.applyOptions({ visible });
});
lineSeriesMapRef.current.forEach((series) => {
series.applyOptions({ visible });
});
}, [visible]);
return null;
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Adjusts the opacity of an rgba color based on confidence
*/
function adjustColorOpacity(color: string, confidence: number): string {
// Extract rgba components
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!match) return color;
const r = match[1];
const g = match[2];
const b = match[3];
const baseOpacity = parseFloat(match[4] || '1');
// Scale opacity by confidence (minimum 0.05 to keep zones visible)
const adjustedOpacity = Math.max(0.05, baseOpacity * confidence);
return `rgba(${r}, ${g}, ${b}, ${adjustedOpacity})`;
}
export default AMDZonesOverlay;

View File

@ -11,3 +11,6 @@ export type { SignalMarkersProps } from './SignalMarkers';
export { ICTConceptsOverlay } from './ICTConceptsOverlay';
export type { ICTConceptsOverlayProps } from './ICTConceptsOverlay';
export { AMDZonesOverlay } from './AMDZonesOverlay';
export type { AMDZonesOverlayProps, AMDZone, AMDPhaseType } from './AMDZonesOverlay';

View File

@ -42,6 +42,15 @@ export interface AMDPhase {
};
}
export interface AMDZone {
phase: 'accumulation' | 'manipulation' | 'distribution' | 'unknown';
startTime: number;
endTime: number;
priceHigh: number;
priceLow: number;
confidence: number;
}
export interface RangePrediction {
symbol: string;
timeframe: string;
@ -113,6 +122,27 @@ export async function getAMDPhase(symbol: string): Promise<AMDPhase | null> {
}
}
/**
* Get historical AMD zones for chart visualization
* Returns zones within the specified time range
*/
export async function getAMDZones(
symbol: string,
timeframe: string = '1h',
limit: number = 10
): Promise<AMDZone[]> {
try {
const response = await apiClient.get(`/proxy/ml/amd/${symbol}/zones`, {
params: { timeframe, limit },
});
return response.data?.zones || response.data || [];
} catch (error) {
if ((error as { response?: { status: number } }).response?.status === 404) return [];
console.error('Error fetching AMD zones:', error);
return [];
}
}
/**
* Get range prediction for a symbol
*/

View File

@ -45,6 +45,35 @@ export interface ICTConcept {
priceBottom: number;
}
// ============================================================================
// AMD Zone Types
// ============================================================================
export type AMDPhaseType = 'accumulation' | 'manipulation' | 'distribution' | 'unknown';
export interface AMDZone {
phase: AMDPhaseType;
startTime: number;
endTime: number;
priceHigh: number;
priceLow: number;
confidence: number;
}
export interface AMDZoneColors {
accumulation: string;
manipulation: string;
distribution: string;
unknown: string;
}
export const DEFAULT_AMD_COLORS: AMDZoneColors = {
accumulation: 'rgba(59, 130, 246, 0.15)', // Blue - calm, building
manipulation: 'rgba(249, 115, 22, 0.15)', // Orange - caution, volatility
distribution: 'rgba(239, 68, 68, 0.15)', // Red - selling pressure
unknown: 'rgba(107, 114, 128, 0.1)', // Gray - uncertain
};
// ============================================================================
// Overlay Configuration Types
// ============================================================================