jashdoshi77 commited on
Commit
e6021a3
·
1 Parent(s): 7e60f9e

whole lotta changes

Browse files
backend/app/main.py CHANGED
@@ -114,7 +114,7 @@ def create_app() -> FastAPI:
114
  )
115
 
116
  # Mount routers
117
- from app.routers import auth, backtests, calendar, copilot, data, holdings, marketplace, ml, portfolios, quant, research, sentiment, strategies
118
 
119
  app.include_router(auth.router, prefix="/api")
120
  app.include_router(data.router, prefix="/api")
@@ -129,6 +129,8 @@ def create_app() -> FastAPI:
129
  app.include_router(sentiment.router)
130
  app.include_router(calendar.router)
131
  app.include_router(copilot.router)
 
 
132
 
133
 
134
  # Health check
 
114
  )
115
 
116
  # Mount routers
117
+ from app.routers import auth, backtests, calendar, copilot, data, holdings, marketplace, ml, patterns, pinescript, portfolios, quant, research, sentiment, strategies
118
 
119
  app.include_router(auth.router, prefix="/api")
120
  app.include_router(data.router, prefix="/api")
 
129
  app.include_router(sentiment.router)
130
  app.include_router(calendar.router)
131
  app.include_router(copilot.router)
132
+ app.include_router(patterns.router, prefix="/api")
133
+ app.include_router(pinescript.router, prefix="/api")
134
 
135
 
136
  # Health check
backend/app/routers/patterns.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pattern Intelligence Router.
3
+
4
+ Endpoints for candlestick pattern detection, ML-based prediction,
5
+ pattern catalog, and historical accuracy analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import List, Optional
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from app.dependencies import get_current_user
17
+ from app.models.user import User
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter(prefix="/patterns", tags=["Pattern Intelligence"])
22
+
23
+
24
+ # ── Schemas ──────────────────────────────────────────────────────────────
25
+
26
+ class PatternAnalyzeRequest(BaseModel):
27
+ ticker: str = Field(..., min_length=1, max_length=20)
28
+ period: str = Field("2y", description="Historical data period")
29
+ horizon: int = Field(5, ge=1, le=30, description="Prediction horizon in days")
30
+
31
+
32
+ class MultiAnalyzeRequest(BaseModel):
33
+ tickers: List[str] = Field(..., min_items=1, max_items=10)
34
+ period: str = Field("2y")
35
+ horizon: int = Field(5, ge=1, le=30)
36
+
37
+
38
+ class BacktestAccuracyRequest(BaseModel):
39
+ ticker: str = Field(..., min_length=1, max_length=20)
40
+ period: str = Field("5y")
41
+ horizon: int = Field(5, ge=1, le=30)
42
+
43
+
44
+ # ── Endpoints ────────────────────────────────────────────────────────────
45
+
46
+ @router.post("/analyze")
47
+ async def analyze_patterns(
48
+ data: PatternAnalyzeRequest,
49
+ user: User = Depends(get_current_user),
50
+ ):
51
+ """
52
+ Detect candlestick patterns and predict price direction using
53
+ pattern-aware LightGBM model with advanced mathematical features.
54
+
55
+ Returns:
56
+ - Predicted direction (strong_up / neutral / strong_down)
57
+ - Confidence and probability distribution
58
+ - Detected patterns with reliability scores
59
+ - Top feature importances
60
+ - Advanced feature values (Hurst, Fractal, Entropy, etc.)
61
+ """
62
+ from app.services.ml.pattern_recognition.predictor import predict_with_patterns
63
+
64
+ try:
65
+ result = await predict_with_patterns(
66
+ ticker=data.ticker,
67
+ period=data.period,
68
+ horizon=data.horizon,
69
+ )
70
+ return result
71
+ except ValueError as e:
72
+ raise HTTPException(status_code=400, detail=str(e))
73
+ except Exception as e:
74
+ logger.error("Pattern analysis failed for %s: %s", data.ticker, e, exc_info=True)
75
+ raise HTTPException(status_code=500, detail="Pattern analysis failed")
76
+
77
+
78
+ @router.post("/multi-analyze")
79
+ async def multi_analyze(
80
+ data: MultiAnalyzeRequest,
81
+ user: User = Depends(get_current_user),
82
+ ):
83
+ """Analyze multiple tickers and return comparative results."""
84
+ from app.services.ml.pattern_recognition.predictor import analyze_multiple
85
+
86
+ try:
87
+ results = await analyze_multiple(
88
+ tickers=data.tickers,
89
+ period=data.period,
90
+ horizon=data.horizon,
91
+ )
92
+ return results
93
+ except Exception as e:
94
+ logger.error("Multi-analysis failed: %s", e, exc_info=True)
95
+ raise HTTPException(status_code=500, detail="Multi-analysis failed")
96
+
97
+
98
+ @router.get("/catalog")
99
+ async def pattern_catalog(
100
+ user: User = Depends(get_current_user),
101
+ ):
102
+ """
103
+ Get the full catalog of all 35+ supported candlestick
104
+ and chart patterns with descriptions and reliability ratings.
105
+ """
106
+ from app.services.ml.pattern_recognition.pattern_detector import pattern_detector
107
+ return {
108
+ "total_patterns": len(pattern_detector.get_pattern_catalog()),
109
+ "patterns": pattern_detector.get_pattern_catalog(),
110
+ }
111
+
112
+
113
+ @router.post("/backtest-accuracy")
114
+ async def backtest_accuracy(
115
+ data: BacktestAccuracyRequest,
116
+ user: User = Depends(get_current_user),
117
+ ):
118
+ """
119
+ Backtest pattern detection accuracy on historical data.
120
+ For each pattern type, returns occurrences, win rate,
121
+ average return, and actual vs theoretical reliability.
122
+ """
123
+ from app.services.ml.pattern_recognition.predictor import backtest_pattern_accuracy
124
+
125
+ try:
126
+ result = await backtest_pattern_accuracy(
127
+ ticker=data.ticker,
128
+ period=data.period,
129
+ horizon=data.horizon,
130
+ )
131
+ return result
132
+ except ValueError as e:
133
+ raise HTTPException(status_code=400, detail=str(e))
134
+ except Exception as e:
135
+ logger.error("Backtest accuracy failed for %s: %s", data.ticker, e, exc_info=True)
136
+ raise HTTPException(status_code=500, detail="Backtest accuracy failed")
137
+
138
+
139
+ @router.post("/clear-cache")
140
+ async def clear_pattern_cache(
141
+ user: User = Depends(get_current_user),
142
+ ):
143
+ """Clear the pattern predictor model cache."""
144
+ from app.services.ml.pattern_recognition.predictor import clear_cache
145
+ count = clear_cache()
146
+ return {"cleared": count}
backend/app/routers/pinescript.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pine Script Lab Router.
3
+
4
+ Endpoints for Pine Script generation, template browsing,
5
+ validation, backtesting, and code customization.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from app.dependencies import get_current_user
17
+ from app.models.user import User
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter(prefix="/pinescript", tags=["Pine Script Lab"])
22
+
23
+
24
+ # ── Schemas ──────────────────────────────────────────────────────────────
25
+
26
+ class GenerateRequest(BaseModel):
27
+ description: str = Field(..., min_length=5, max_length=2000, description="Natural language description")
28
+ parameters: Optional[Dict[str, Any]] = None
29
+
30
+
31
+ class TemplateRequest(BaseModel):
32
+ template_id: str = Field(..., min_length=1, max_length=50)
33
+ parameters: Optional[Dict[str, Any]] = None
34
+
35
+
36
+ class ValidateRequest(BaseModel):
37
+ code: str = Field(..., min_length=10, max_length=10000)
38
+
39
+
40
+ class BacktestRequest(BaseModel):
41
+ code: str = Field(..., min_length=10, max_length=10000)
42
+ ticker: str = Field("SPY", min_length=1, max_length=20)
43
+ period: str = Field("3y")
44
+ initial_capital: float = Field(100000, ge=1000)
45
+ commission_pct: float = Field(0.1, ge=0, le=5)
46
+
47
+
48
+ class CustomizeRequest(BaseModel):
49
+ code: str = Field(..., min_length=10, max_length=10000)
50
+ modification: str = Field(..., min_length=5, max_length=2000)
51
+
52
+
53
+ # ── Endpoints ────────────────────────────────────────────────────────────
54
+
55
+ @router.post("/generate")
56
+ async def generate_pinescript(
57
+ data: GenerateRequest,
58
+ user: User = Depends(get_current_user),
59
+ ):
60
+ """
61
+ Generate Pine Script v5 code from a natural language description.
62
+ Uses LLM (Groq) for intelligent code generation, falling back
63
+ to template matching if LLM is unavailable.
64
+ """
65
+ from app.services.pinescript.generator import generate_from_description
66
+
67
+ try:
68
+ result = await generate_from_description(
69
+ description=data.description,
70
+ parameters=data.parameters,
71
+ )
72
+ return result
73
+ except Exception as e:
74
+ logger.error("Pine Script generation failed: %s", e, exc_info=True)
75
+ raise HTTPException(status_code=500, detail="Generation failed")
76
+
77
+
78
+ @router.get("/templates")
79
+ async def list_templates(
80
+ user: User = Depends(get_current_user),
81
+ ):
82
+ """List all available strategy templates."""
83
+ from app.services.pinescript.generator import get_all_templates
84
+ templates = get_all_templates()
85
+ return {"total": len(templates), "templates": templates}
86
+
87
+
88
+ @router.post("/templates/generate")
89
+ async def generate_from_template_endpoint(
90
+ data: TemplateRequest,
91
+ user: User = Depends(get_current_user),
92
+ ):
93
+ """Generate Pine Script from a specific template with optional parameter overrides."""
94
+ from app.services.pinescript.generator import generate_from_template
95
+
96
+ try:
97
+ result = generate_from_template(
98
+ template_id=data.template_id,
99
+ parameters=data.parameters,
100
+ )
101
+ return result
102
+ except ValueError as e:
103
+ raise HTTPException(status_code=404, detail=str(e))
104
+
105
+
106
+ @router.post("/validate")
107
+ async def validate_pinescript(
108
+ data: ValidateRequest,
109
+ user: User = Depends(get_current_user),
110
+ ):
111
+ """Validate Pine Script v5 syntax."""
112
+ from app.services.pinescript.validator import validate_pine_script
113
+ return validate_pine_script(data.code)
114
+
115
+
116
+ @router.post("/backtest")
117
+ async def backtest_pinescript(
118
+ data: BacktestRequest,
119
+ user: User = Depends(get_current_user),
120
+ ):
121
+ """
122
+ Run a backtest on Pine Script code using historical data.
123
+ Returns TradingView-style performance metrics, equity curve,
124
+ trade log, and monthly returns.
125
+ """
126
+ from app.services.pinescript.pine_backtester import pine_backtester
127
+
128
+ try:
129
+ result = await pine_backtester.backtest(
130
+ code=data.code,
131
+ ticker=data.ticker,
132
+ period=data.period,
133
+ initial_capital=data.initial_capital,
134
+ commission_pct=data.commission_pct,
135
+ )
136
+ return result
137
+ except ValueError as e:
138
+ raise HTTPException(status_code=400, detail=str(e))
139
+ except Exception as e:
140
+ logger.error("Backtest failed: %s", e, exc_info=True)
141
+ raise HTTPException(status_code=500, detail="Backtest failed")
142
+
143
+
144
+ @router.post("/customize")
145
+ async def customize_pinescript(
146
+ data: CustomizeRequest,
147
+ user: User = Depends(get_current_user),
148
+ ):
149
+ """Modify existing Pine Script via LLM-powered customization."""
150
+ from app.services.pinescript.generator import customize_code
151
+
152
+ try:
153
+ result = await customize_code(
154
+ existing_code=data.code,
155
+ modification=data.modification,
156
+ )
157
+ return result
158
+ except Exception as e:
159
+ logger.error("Customization failed: %s", e, exc_info=True)
160
+ raise HTTPException(status_code=500, detail="Customization failed")
backend/app/services/ml/pattern_recognition/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Pattern Recognition ML Engine
backend/app/services/ml/pattern_recognition/advanced_features.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced Mathematical Features for Pattern Recognition.
3
+
4
+ Institutional-grade feature engineering beyond standard technical indicators.
5
+ These features capture deep market microstructure that traditional indicators miss.
6
+
7
+ Features:
8
+ - Fourier Transform (spectral analysis, dominant frequencies)
9
+ - Fractal Dimension (market roughness via Higuchi method)
10
+ - Hurst Exponent (trending vs mean-reverting via R/S analysis)
11
+ - Shannon Entropy (market randomness/uncertainty)
12
+ - Autocorrelation Decay (momentum persistence)
13
+ - Volume-Price Correlation (smart money detection)
14
+ - Trend Strength Index (custom composite)
15
+ - Market Microstructure Features (tick patterns, gap analysis)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from typing import Any, Dict, List
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class AdvancedFeatureEngine:
30
+ """Compute advanced mathematical features from OHLCV data."""
31
+
32
+ def compute_all(self, df: pd.DataFrame) -> Dict[str, Any]:
33
+ """
34
+ Compute all advanced features and return as a flat dict.
35
+
36
+ Returns both raw feature values and a features DataFrame
37
+ that can be used for ML model input.
38
+ """
39
+ if df.empty or len(df) < 30:
40
+ return {"error": "Insufficient data (need 30+ bars)", "features": {}}
41
+
42
+ close = df["Close"].values.astype(float)
43
+ high = df["High"].values.astype(float)
44
+ low = df["Low"].values.astype(float)
45
+ volume = df["Volume"].values.astype(float) if "Volume" in df.columns else np.ones(len(close))
46
+
47
+ features: Dict[str, Any] = {}
48
+
49
+ # 1. Fourier Transform Analysis
50
+ try:
51
+ ft = self._fourier_analysis(close)
52
+ features.update(ft)
53
+ except Exception as e:
54
+ logger.debug("Fourier analysis failed: %s", e)
55
+
56
+ # 2. Fractal Dimension (Higuchi)
57
+ try:
58
+ features["fractal_dimension"] = self._higuchi_fractal_dimension(close)
59
+ except Exception as e:
60
+ logger.debug("Fractal dimension failed: %s", e)
61
+
62
+ # 3. Hurst Exponent
63
+ try:
64
+ features["hurst_exponent"] = self._hurst_exponent(close)
65
+ features["hurst_regime"] = (
66
+ "trending" if features["hurst_exponent"] > 0.55
67
+ else "mean_reverting" if features["hurst_exponent"] < 0.45
68
+ else "random_walk"
69
+ )
70
+ except Exception as e:
71
+ logger.debug("Hurst exponent failed: %s", e)
72
+
73
+ # 4. Shannon Entropy
74
+ try:
75
+ features["entropy_returns"] = self._shannon_entropy(close, bins=20)
76
+ features["entropy_volume"] = self._shannon_entropy(volume, bins=20)
77
+ except Exception as e:
78
+ logger.debug("Entropy failed: %s", e)
79
+
80
+ # 5. Autocorrelation Decay
81
+ try:
82
+ acf = self._autocorrelation_profile(close)
83
+ features.update(acf)
84
+ except Exception as e:
85
+ logger.debug("Autocorrelation failed: %s", e)
86
+
87
+ # 6. Volume-Price Correlation
88
+ try:
89
+ vpc = self._volume_price_analysis(close, volume)
90
+ features.update(vpc)
91
+ except Exception as e:
92
+ logger.debug("Volume-price analysis failed: %s", e)
93
+
94
+ # 7. Trend Strength Index
95
+ try:
96
+ features["trend_strength"] = self._trend_strength_index(close, high, low)
97
+ except Exception as e:
98
+ logger.debug("Trend strength failed: %s", e)
99
+
100
+ # 8. Gap Analysis
101
+ try:
102
+ gap = self._gap_analysis(df)
103
+ features.update(gap)
104
+ except Exception as e:
105
+ logger.debug("Gap analysis failed: %s", e)
106
+
107
+ # 9. Price Efficiency Ratio
108
+ try:
109
+ features["price_efficiency"] = self._price_efficiency_ratio(close)
110
+ except Exception as e:
111
+ logger.debug("Price efficiency failed: %s", e)
112
+
113
+ # 10. Kurtosis & Skewness of returns
114
+ try:
115
+ returns = np.diff(np.log(close + 1e-10))
116
+ if len(returns) >= 10:
117
+ features["return_skewness"] = float(pd.Series(returns).skew())
118
+ features["return_kurtosis"] = float(pd.Series(returns).kurtosis())
119
+ except Exception as e:
120
+ logger.debug("Moments failed: %s", e)
121
+
122
+ return features
123
+
124
+ def compute_feature_series(self, df: pd.DataFrame, window: int = 20) -> pd.DataFrame:
125
+ """
126
+ Compute rolling advanced features as a DataFrame for ML training.
127
+ Returns one row per bar with multiple feature columns.
128
+ """
129
+ result = df.copy()
130
+ close = df["Close"].values.astype(float)
131
+ volume = df["Volume"].values.astype(float) if "Volume" in df.columns else np.ones(len(close))
132
+
133
+ n = len(df)
134
+ hurst_vals = np.full(n, np.nan)
135
+ fractal_vals = np.full(n, np.nan)
136
+ entropy_vals = np.full(n, np.nan)
137
+ efficiency_vals = np.full(n, np.nan)
138
+ trend_vals = np.full(n, np.nan)
139
+
140
+ for i in range(window, n):
141
+ segment = close[i - window:i + 1]
142
+ vol_seg = volume[i - window:i + 1]
143
+
144
+ try:
145
+ hurst_vals[i] = self._hurst_exponent(segment)
146
+ except Exception:
147
+ pass
148
+ try:
149
+ fractal_vals[i] = self._higuchi_fractal_dimension(segment)
150
+ except Exception:
151
+ pass
152
+ try:
153
+ entropy_vals[i] = self._shannon_entropy(segment, bins=10)
154
+ except Exception:
155
+ pass
156
+ try:
157
+ efficiency_vals[i] = self._price_efficiency_ratio(segment)
158
+ except Exception:
159
+ pass
160
+ try:
161
+ high_seg = df["High"].values[i - window:i + 1].astype(float)
162
+ low_seg = df["Low"].values[i - window:i + 1].astype(float)
163
+ trend_vals[i] = self._trend_strength_index(segment, high_seg, low_seg)
164
+ except Exception:
165
+ pass
166
+
167
+ result["hurst_exponent"] = hurst_vals
168
+ result["fractal_dimension"] = fractal_vals
169
+ result["entropy"] = entropy_vals
170
+ result["price_efficiency"] = efficiency_vals
171
+ result["trend_strength"] = trend_vals
172
+
173
+ # Return-based distribution features (rolling)
174
+ returns = pd.Series(np.log(df["Close"] / df["Close"].shift(1)))
175
+ result["return_skew_20"] = returns.rolling(window).skew()
176
+ result["return_kurtosis_20"] = returns.rolling(window).apply(
177
+ lambda x: float(pd.Series(x).kurtosis()), raw=False
178
+ )
179
+
180
+ return result
181
+
182
+ # ── Fourier Analysis ─────────────────────────────────────────────────
183
+
184
+ def _fourier_analysis(self, prices: np.ndarray, top_k: int = 5) -> Dict[str, Any]:
185
+ """FFT spectral analysis of price series."""
186
+ log_prices = np.log(prices + 1e-10)
187
+ detrended = log_prices - np.linspace(log_prices[0], log_prices[-1], len(log_prices))
188
+
189
+ fft_vals = np.fft.rfft(detrended)
190
+ magnitudes = np.abs(fft_vals)
191
+ freqs = np.fft.rfftfreq(len(detrended))
192
+
193
+ # Skip DC component (index 0)
194
+ if len(magnitudes) > 1:
195
+ magnitudes = magnitudes[1:]
196
+ freqs = freqs[1:]
197
+
198
+ if len(magnitudes) == 0:
199
+ return {}
200
+
201
+ # Top dominant frequencies
202
+ top_indices = np.argsort(magnitudes)[-top_k:][::-1]
203
+ total_energy = np.sum(magnitudes ** 2)
204
+
205
+ dominant_periods = []
206
+ for idx in top_indices:
207
+ if freqs[idx] > 0:
208
+ period = 1.0 / freqs[idx]
209
+ energy_pct = (magnitudes[idx] ** 2) / total_energy * 100 if total_energy > 0 else 0
210
+ dominant_periods.append({
211
+ "period_bars": round(period, 1),
212
+ "energy_pct": round(energy_pct, 2),
213
+ })
214
+
215
+ # Spectral energy ratios
216
+ low_freq = magnitudes[:max(1, len(magnitudes)//4)]
217
+ high_freq = magnitudes[len(magnitudes)//4:]
218
+ low_energy = np.sum(low_freq ** 2)
219
+ high_energy = np.sum(high_freq ** 2)
220
+
221
+ return {
222
+ "fft_dominant_periods": dominant_periods[:3],
223
+ "fft_spectral_ratio": round(low_energy / (high_energy + 1e-10), 4),
224
+ "fft_total_energy": round(float(total_energy), 4),
225
+ }
226
+
227
+ # ── Fractal Dimension (Higuchi) ──────────────────────────────────────
228
+
229
+ def _higuchi_fractal_dimension(self, x: np.ndarray, k_max: int = 10) -> float:
230
+ """Higuchi fractal dimension — measures market roughness."""
231
+ n = len(x)
232
+ if n < k_max + 1:
233
+ k_max = max(2, n // 2)
234
+
235
+ lk = np.zeros(k_max)
236
+ for k in range(1, k_max + 1):
237
+ lm_sum = 0.0
238
+ for m in range(1, k + 1):
239
+ indices = np.arange(0, (n - m) // k) * k + m - 1
240
+ if len(indices) < 2:
241
+ continue
242
+ segment = x[indices.astype(int)]
243
+ length = np.sum(np.abs(np.diff(segment))) * (n - 1) / (k * len(segment))
244
+ lm_sum += length
245
+ lk[k - 1] = lm_sum / k if k > 0 else 0
246
+
247
+ # Fit log-log regression
248
+ valid = lk > 0
249
+ if np.sum(valid) < 2:
250
+ return 1.5 # default
251
+
252
+ ks = np.arange(1, k_max + 1)[valid]
253
+ log_k = np.log(1.0 / ks)
254
+ log_lk = np.log(lk[valid])
255
+
256
+ slope = np.polyfit(log_k, log_lk, 1)[0]
257
+ return round(float(slope), 4)
258
+
259
+ # ── Hurst Exponent (R/S Analysis) ────────────────────────────────────
260
+
261
+ def _hurst_exponent(self, prices: np.ndarray) -> float:
262
+ """
263
+ Rescaled range (R/S) Hurst exponent.
264
+ H > 0.5: trending, H < 0.5: mean-reverting, H ≈ 0.5: random walk
265
+ """
266
+ returns = np.diff(np.log(prices + 1e-10))
267
+ n = len(returns)
268
+ if n < 20:
269
+ return 0.5
270
+
271
+ max_k = min(n // 2, 100)
272
+ divisions = [d for d in range(10, max_k + 1, max(1, max_k // 20))]
273
+ if len(divisions) < 3:
274
+ return 0.5
275
+
276
+ rs_values = []
277
+ sizes = []
278
+ for d in divisions:
279
+ n_segments = n // d
280
+ if n_segments < 1:
281
+ continue
282
+ rs_list = []
283
+ for seg in range(n_segments):
284
+ segment = returns[seg * d:(seg + 1) * d]
285
+ mean_seg = np.mean(segment)
286
+ cumdev = np.cumsum(segment - mean_seg)
287
+ r = np.max(cumdev) - np.min(cumdev)
288
+ s = np.std(segment, ddof=1)
289
+ if s > 0:
290
+ rs_list.append(r / s)
291
+ if rs_list:
292
+ rs_values.append(np.mean(rs_list))
293
+ sizes.append(d)
294
+
295
+ if len(sizes) < 3:
296
+ return 0.5
297
+
298
+ log_sizes = np.log(np.array(sizes, dtype=float))
299
+ log_rs = np.log(np.array(rs_values, dtype=float))
300
+ slope = np.polyfit(log_sizes, log_rs, 1)[0]
301
+ return round(float(np.clip(slope, 0, 1)), 4)
302
+
303
+ # ── Shannon Entropy ──────────────────────────────────────────────────
304
+
305
+ def _shannon_entropy(self, data: np.ndarray, bins: int = 20) -> float:
306
+ """Shannon entropy of data distribution. Higher = more random."""
307
+ if len(data) < 5:
308
+ return 0.0
309
+ hist, _ = np.histogram(data, bins=bins, density=True)
310
+ hist = hist[hist > 0]
311
+ if len(hist) == 0:
312
+ return 0.0
313
+ hist = hist / hist.sum() # normalize
314
+ return round(float(-np.sum(hist * np.log2(hist + 1e-12))), 4)
315
+
316
+ # ── Autocorrelation Profile ──────────────────────────────────────────
317
+
318
+ def _autocorrelation_profile(self, prices: np.ndarray) -> Dict[str, float]:
319
+ """Compute autocorrelation at multiple lags."""
320
+ returns = np.diff(np.log(prices + 1e-10))
321
+ if len(returns) < 20:
322
+ return {}
323
+
324
+ result = {}
325
+ for lag in [1, 3, 5, 10, 20]:
326
+ if lag < len(returns):
327
+ acf = np.corrcoef(returns[lag:], returns[:-lag])[0, 1]
328
+ result[f"acf_lag_{lag}"] = round(float(acf), 4) if np.isfinite(acf) else 0.0
329
+
330
+ # Decay rate: how fast autocorrelation drops
331
+ acf_values = [result.get(f"acf_lag_{l}", 0) for l in [1, 5, 10, 20]]
332
+ result["acf_decay_rate"] = round(
333
+ float(np.polyfit(range(len(acf_values)), acf_values, 1)[0]), 6
334
+ )
335
+
336
+ return result
337
+
338
+ # ── Volume-Price Analysis ────────────────────────────────────────────
339
+
340
+ def _volume_price_analysis(
341
+ self, prices: np.ndarray, volume: np.ndarray
342
+ ) -> Dict[str, float]:
343
+ """Analyze volume-price relationship for smart money detection."""
344
+ returns = np.diff(np.log(prices + 1e-10))
345
+ vol = volume[1:] # align with returns
346
+
347
+ if len(returns) < 10:
348
+ return {}
349
+
350
+ # Volume-return correlation
351
+ corr = np.corrcoef(returns, vol)[0, 1]
352
+ abs_corr = np.corrcoef(np.abs(returns), vol)[0, 1]
353
+
354
+ # Volume on up vs down days
355
+ up_mask = returns > 0
356
+ down_mask = returns < 0
357
+ avg_up_vol = np.mean(vol[up_mask]) if up_mask.sum() > 0 else 0
358
+ avg_down_vol = np.mean(vol[down_mask]) if down_mask.sum() > 0 else 0
359
+ vol_asymmetry = (avg_up_vol - avg_down_vol) / (avg_up_vol + avg_down_vol + 1e-10)
360
+
361
+ return {
362
+ "volume_return_corr": round(float(corr), 4) if np.isfinite(corr) else 0,
363
+ "volume_abs_return_corr": round(float(abs_corr), 4) if np.isfinite(abs_corr) else 0,
364
+ "volume_asymmetry": round(float(vol_asymmetry), 4),
365
+ }
366
+
367
+ # ── Trend Strength Index ─────────────────────────────────────────────
368
+
369
+ def _trend_strength_index(
370
+ self, close: np.ndarray, high: np.ndarray, low: np.ndarray
371
+ ) -> float:
372
+ """
373
+ Custom composite trend strength (0 = no trend, 1 = strong trend).
374
+ Combines ADX-like directional movement with price efficiency.
375
+ """
376
+ n = len(close)
377
+ if n < 14:
378
+ return 0.5
379
+
380
+ # Directional movement
381
+ dm_plus = np.maximum(np.diff(high), 0)
382
+ dm_minus = np.maximum(-np.diff(low), 0)
383
+
384
+ # Nullify weaker direction
385
+ both = dm_plus > dm_minus
386
+ dm_plus[~both] = 0
387
+ dm_minus[both] = 0
388
+
389
+ # Smoothed (14-period average)
390
+ period = min(14, len(dm_plus))
391
+ avg_dm_plus = np.mean(dm_plus[-period:])
392
+ avg_dm_minus = np.mean(dm_minus[-period:])
393
+ total_dm = avg_dm_plus + avg_dm_minus
394
+
395
+ if total_dm == 0:
396
+ return 0.0
397
+
398
+ dx = abs(avg_dm_plus - avg_dm_minus) / total_dm
399
+
400
+ # Combine with efficiency
401
+ efficiency = self._price_efficiency_ratio(close)
402
+
403
+ return round(float((dx + efficiency) / 2), 4)
404
+
405
+ # ── Price Efficiency Ratio ───────────────────────────────────────────
406
+
407
+ def _price_efficiency_ratio(self, prices: np.ndarray) -> float:
408
+ """
409
+ Kaufman Efficiency Ratio: net movement / total path length.
410
+ 1.0 = perfect trend, 0.0 = pure noise.
411
+ """
412
+ if len(prices) < 5:
413
+ return 0.5
414
+ net_change = abs(prices[-1] - prices[0])
415
+ total_path = np.sum(np.abs(np.diff(prices)))
416
+ return round(float(net_change / (total_path + 1e-10)), 4)
417
+
418
+ # ── Gap Analysis ─────────────────────────────────────────────────────
419
+
420
+ def _gap_analysis(self, df: pd.DataFrame) -> Dict[str, Any]:
421
+ """Analyze price gaps for institutional activity detection."""
422
+ if len(df) < 10:
423
+ return {}
424
+
425
+ opens = df["Open"].values
426
+ prev_closes = df["Close"].shift(1).values
427
+ gaps = (opens[1:] - prev_closes[1:]) / (prev_closes[1:] + 1e-10)
428
+
429
+ gap_ups = gaps[gaps > 0.005]
430
+ gap_downs = gaps[gaps < -0.005]
431
+
432
+ return {
433
+ "gap_up_count_20": int(np.sum(gaps[-20:] > 0.005)) if len(gaps) >= 20 else 0,
434
+ "gap_down_count_20": int(np.sum(gaps[-20:] < -0.005)) if len(gaps) >= 20 else 0,
435
+ "avg_gap_size": round(float(np.mean(np.abs(gaps[-20:])) * 100), 4) if len(gaps) >= 20 else 0,
436
+ "max_gap_pct": round(float(np.max(np.abs(gaps[-20:])) * 100), 4) if len(gaps) >= 20 else 0,
437
+ }
438
+
439
+
440
+ # Module singleton
441
+ advanced_feature_engine = AdvancedFeatureEngine()
backend/app/services/ml/pattern_recognition/pattern_detector.py ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Candlestick Pattern Detector — 35+ Patterns.
3
+
4
+ Institutional-grade pattern detection across single-candle, multi-candle,
5
+ and complex chart patterns.
6
+
7
+ Trading Styles Covered:
8
+ - Scalping: sub-minute to minutes, high-frequency entries
9
+ - Day Trading / Intraday: same-day open-close positions
10
+ - Swing Trading: multi-day to multi-week reversals/continuations
11
+ - Positional / Long-Term: weeks to months trend riding
12
+ - F&O / Options: volatility-based, breakout/breakdown detection
13
+ - Futures: momentum-based, trend-following signals
14
+
15
+ Market Coverage:
16
+ - Equities (US, India, Europe, Asia)
17
+ - Forex (major, minor, exotic pairs)
18
+ - Crypto (BTC, ETH, altcoins)
19
+ - Commodities (gold, oil, agricultural)
20
+ - Indices (SPX, NIFTY, DAX)
21
+
22
+ Each detected pattern returns:
23
+ - name, category, direction (bullish/bearish/neutral)
24
+ - reliability_score (0-1), body_ratio, shadow_ratios
25
+ - trading_styles: list of suitable trading approaches
26
+ - markets: list of suitable market types
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ from dataclasses import dataclass
33
+ from typing import Any, Dict, List, Optional
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ @dataclass
42
+ class PatternResult:
43
+ """A detected candlestick or chart pattern."""
44
+ name: str
45
+ category: str # single, multi, complex
46
+ direction: str # bullish, bearish, neutral
47
+ reliability: float # 0.0 – 1.0
48
+ index: int # bar index where pattern was detected
49
+ description: str
50
+ trading_styles: List[str] # scalping, intraday, swing, positional, options, futures, forex, crypto, commodities
51
+ details: Dict[str, Any]
52
+
53
+ @property
54
+ def hedge_signal(self) -> str:
55
+ """Auto-derive hedge action from pattern direction + reliability.
56
+
57
+ Returns one of:
58
+ - 'hedge_now' — Strong bearish signal, open/increase hedge immediately
59
+ - 'increase_hedge' — Moderate bearish signal, consider tightening protection
60
+ - 'reduce_hedge' — Strong bullish signal, reduce hedge exposure
61
+ - 'hold_hedge' — Neutral / indecision, maintain current hedge level
62
+ """
63
+ if self.direction == "bearish" and self.reliability >= 0.70:
64
+ return "hedge_now"
65
+ elif self.direction == "bearish":
66
+ return "increase_hedge"
67
+ elif self.direction == "bullish" and self.reliability >= 0.70:
68
+ return "reduce_hedge"
69
+ else:
70
+ return "hold_hedge"
71
+
72
+ @property
73
+ def hedge_recommendation(self) -> Dict[str, Any]:
74
+ """Hedge sizing recommendation based on signal strength."""
75
+ signal = self.hedge_signal
76
+ if signal == "hedge_now":
77
+ return {
78
+ "action": "hedge_now",
79
+ "suggested_hedge_pct": min(50, int(self.reliability * 60)),
80
+ "urgency": "high",
81
+ "rationale": f"{self.name} ({self.reliability:.0%} reliability) indicates strong downside risk. Protect portfolio with 30-50% hedge allocation.",
82
+ }
83
+ elif signal == "increase_hedge":
84
+ return {
85
+ "action": "increase_hedge",
86
+ "suggested_hedge_pct": min(30, int(self.reliability * 40)),
87
+ "urgency": "medium",
88
+ "rationale": f"{self.name} signals bearish pressure. Consider increasing hedge to 15-30%.",
89
+ }
90
+ elif signal == "reduce_hedge":
91
+ return {
92
+ "action": "reduce_hedge",
93
+ "suggested_hedge_pct": max(5, int((1 - self.reliability) * 20)),
94
+ "urgency": "low",
95
+ "rationale": f"{self.name} ({self.reliability:.0%} reliability) confirms bullish momentum. Reduce hedge to 5-10% maintenance level.",
96
+ }
97
+ else:
98
+ return {
99
+ "action": "hold_hedge",
100
+ "suggested_hedge_pct": 15,
101
+ "urgency": "none",
102
+ "rationale": f"{self.name} is neutral/indecisive. Maintain current hedge level.",
103
+ }
104
+
105
+ def to_dict(self) -> Dict[str, Any]:
106
+ return {
107
+ "name": self.name,
108
+ "category": self.category,
109
+ "direction": self.direction,
110
+ "reliability": round(self.reliability, 4),
111
+ "index": self.index,
112
+ "description": self.description,
113
+ "trading_styles": self.trading_styles,
114
+ "hedge_signal": self.hedge_signal,
115
+ "hedge_recommendation": self.hedge_recommendation,
116
+ "details": self.details,
117
+ }
118
+
119
+
120
+ # ── Helper Functions ─────────────────────────────────────────────────────
121
+
122
+ def _body(o: float, c: float) -> float:
123
+ """Absolute body size."""
124
+ return abs(c - o)
125
+
126
+ def _range(h: float, l: float) -> float:
127
+ """Candle range (high-low)."""
128
+ return h - l
129
+
130
+ def _upper_shadow(o: float, h: float, c: float) -> float:
131
+ return h - max(o, c)
132
+
133
+ def _lower_shadow(o: float, l: float, c: float) -> float:
134
+ return min(o, c) - l
135
+
136
+ def _is_bullish(o: float, c: float) -> bool:
137
+ return c > o
138
+
139
+ def _is_bearish(o: float, c: float) -> bool:
140
+ return c < o
141
+
142
+ def _body_ratio(o: float, h: float, l: float, c: float) -> float:
143
+ """Body as fraction of total range."""
144
+ r = _range(h, l)
145
+ return _body(o, c) / r if r > 0 else 0
146
+
147
+ def _avg_body(df: pd.DataFrame, end: int, lookback: int = 14) -> float:
148
+ """Average body size over lookback period."""
149
+ start = max(0, end - lookback)
150
+ bodies = [_body(df.iloc[i]["Open"], df.iloc[i]["Close"]) for i in range(start, end)]
151
+ return np.mean(bodies) if bodies else 1.0
152
+
153
+
154
+ # ── Single-Candle Patterns ───────────────────────────────────────────────
155
+
156
+ class SingleCandleDetector:
157
+ """Detect single-candle patterns."""
158
+
159
+ @staticmethod
160
+ def detect(df: pd.DataFrame, idx: int, avg_b: float) -> List[PatternResult]:
161
+ results: List[PatternResult] = []
162
+ if idx < 0 or idx >= len(df):
163
+ return results
164
+
165
+ row = df.iloc[idx]
166
+ o, h, l, c = row["Open"], row["High"], row["Low"], row["Close"]
167
+ body = _body(o, c)
168
+ rng = _range(h, l)
169
+ if rng <= 0:
170
+ return results
171
+
172
+ br = body / rng
173
+ us = _upper_shadow(o, h, c)
174
+ ls = _lower_shadow(o, l, c)
175
+ bullish = _is_bullish(o, c)
176
+ bearish = _is_bearish(o, c)
177
+
178
+ # 1. Doji — body < 5% of range
179
+ if br < 0.05:
180
+ # Sub-types
181
+ if ls > rng * 0.3 and us < rng * 0.1:
182
+ results.append(PatternResult(
183
+ name="Dragonfly Doji", category="single", direction="bullish",
184
+ reliability=0.72, index=idx,
185
+ description="Open and close near high with long lower shadow. Strong bullish reversal when found at support.",
186
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto"],
187
+ details={"body_ratio": round(br, 4), "lower_shadow_pct": round(ls/rng, 4)}
188
+ ))
189
+ elif us > rng * 0.3 and ls < rng * 0.1:
190
+ results.append(PatternResult(
191
+ name="Gravestone Doji", category="single", direction="bearish",
192
+ reliability=0.71, index=idx,
193
+ description="Open and close near low with long upper shadow. Bearish reversal at resistance.",
194
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto"],
195
+ details={"body_ratio": round(br, 4), "upper_shadow_pct": round(us/rng, 4)}
196
+ ))
197
+ elif us > rng * 0.3 and ls > rng * 0.3:
198
+ results.append(PatternResult(
199
+ name="Long-Legged Doji", category="single", direction="neutral",
200
+ reliability=0.60, index=idx,
201
+ description="Equal long shadows on both sides. Indicates extreme indecision — high volatility expected.",
202
+ trading_styles=["scalping", "intraday", "swing", "options", "futures", "forex", "crypto", "commodities"],
203
+ details={"body_ratio": round(br, 4)}
204
+ ))
205
+ else:
206
+ results.append(PatternResult(
207
+ name="Doji", category="single", direction="neutral",
208
+ reliability=0.55, index=idx,
209
+ description="Open equals close. Market indecision — potential reversal signal when confirmed.",
210
+ trading_styles=["scalping", "intraday", "swing", "forex", "crypto"],
211
+ details={"body_ratio": round(br, 4)}
212
+ ))
213
+
214
+ # 2. Hammer — small body at top, long lower shadow (>2x body)
215
+ if br < 0.35 and ls >= body * 2 and us < body * 0.5 and body > 0:
216
+ results.append(PatternResult(
217
+ name="Hammer", category="single", direction="bullish",
218
+ reliability=0.75, index=idx,
219
+ description="Small body at top, long lower shadow. Classic bullish reversal pattern at support levels.",
220
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
221
+ details={"body_ratio": round(br, 4), "shadow_body_ratio": round(ls/body, 2) if body > 0 else 0}
222
+ ))
223
+
224
+ # 3. Hanging Man — same shape as hammer but at top of uptrend
225
+ if br < 0.35 and ls >= body * 2 and us < body * 0.5 and body > 0:
226
+ # Differentiated from hammer by context (checked in multi-candle)
227
+ results.append(PatternResult(
228
+ name="Hanging Man", category="single", direction="bearish",
229
+ reliability=0.65, index=idx,
230
+ description="Hammer shape at top of uptrend. Warns of potential trend reversal downward.",
231
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto"],
232
+ details={"body_ratio": round(br, 4)}
233
+ ))
234
+
235
+ # 4. Inverted Hammer — small body at bottom, long upper shadow
236
+ if br < 0.35 and us >= body * 2 and ls < body * 0.5 and body > 0:
237
+ results.append(PatternResult(
238
+ name="Inverted Hammer", category="single", direction="bullish",
239
+ reliability=0.65, index=idx,
240
+ description="Small body at bottom, long upper shadow. Bullish reversal candidate requiring confirmation.",
241
+ trading_styles=["scalping", "intraday", "swing", "options", "forex", "crypto"],
242
+ details={"body_ratio": round(br, 4)}
243
+ ))
244
+
245
+ # 5. Shooting Star — small body at bottom, long upper shadow (bearish)
246
+ if br < 0.35 and us >= body * 2 and ls < body * 0.5 and body > 0:
247
+ results.append(PatternResult(
248
+ name="Shooting Star", category="single", direction="bearish",
249
+ reliability=0.72, index=idx,
250
+ description="Small body near low, long upper shadow. Strong bearish reversal at resistance.",
251
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
252
+ details={"body_ratio": round(br, 4)}
253
+ ))
254
+
255
+ # 6. Marubozu (Bullish) — large body, almost no shadows
256
+ if br > 0.90 and bullish:
257
+ results.append(PatternResult(
258
+ name="Bullish Marubozu", category="single", direction="bullish",
259
+ reliability=0.78, index=idx,
260
+ description="Full bullish candle with no or tiny shadows. Extreme buying pressure, strong continuation signal.",
261
+ trading_styles=["scalping", "intraday", "swing", "positional", "futures", "forex", "crypto", "commodities"],
262
+ details={"body_ratio": round(br, 4)}
263
+ ))
264
+
265
+ # 7. Marubozu (Bearish)
266
+ if br > 0.90 and bearish:
267
+ results.append(PatternResult(
268
+ name="Bearish Marubozu", category="single", direction="bearish",
269
+ reliability=0.78, index=idx,
270
+ description="Full bearish candle with no or tiny shadows. Extreme selling pressure, strong continuation signal.",
271
+ trading_styles=["scalping", "intraday", "swing", "positional", "futures", "forex", "crypto", "commodities"],
272
+ details={"body_ratio": round(br, 4)}
273
+ ))
274
+
275
+ # 8. Spinning Top — small body, shadows on both sides
276
+ if 0.05 <= br <= 0.35 and us > body * 0.5 and ls > body * 0.5 and body > 0:
277
+ results.append(PatternResult(
278
+ name="Spinning Top", category="single", direction="neutral",
279
+ reliability=0.45, index=idx,
280
+ description="Small body with shadows on both sides. Indecision between buyers and sellers.",
281
+ trading_styles=["scalping", "intraday", "swing", "forex", "crypto"],
282
+ details={"body_ratio": round(br, 4)}
283
+ ))
284
+
285
+ # 9. High Wave Candle — very small body, very long shadows
286
+ if br < 0.15 and us > rng * 0.35 and ls > rng * 0.35:
287
+ results.append(PatternResult(
288
+ name="High Wave Candle", category="single", direction="neutral",
289
+ reliability=0.55, index=idx,
290
+ description="Tiny body with extremely long shadows. Signals major indecision and potential reversal.",
291
+ trading_styles=["intraday", "swing", "options", "futures", "forex", "crypto"],
292
+ details={"body_ratio": round(br, 4)}
293
+ ))
294
+
295
+ # 10. Belt Hold (Bullish) — opens at low, closes near high, large body
296
+ if bullish and br > 0.6 and ls < rng * 0.05 and body > avg_b * 1.2:
297
+ results.append(PatternResult(
298
+ name="Bullish Belt Hold", category="single", direction="bullish",
299
+ reliability=0.68, index=idx,
300
+ description="Opens at/near low, strong close near high. Powerful bullish opening signal.",
301
+ trading_styles=["intraday", "swing", "positional", "futures", "forex", "crypto", "commodities"],
302
+ details={"body_ratio": round(br, 4), "body_vs_avg": round(body/avg_b, 2) if avg_b > 0 else 0}
303
+ ))
304
+
305
+ # 11. Belt Hold (Bearish)
306
+ if bearish and br > 0.6 and us < rng * 0.05 and body > avg_b * 1.2:
307
+ results.append(PatternResult(
308
+ name="Bearish Belt Hold", category="single", direction="bearish",
309
+ reliability=0.68, index=idx,
310
+ description="Opens at/near high, closes near low. Strong bearish opening signal.",
311
+ trading_styles=["intraday", "swing", "positional", "futures", "forex", "crypto", "commodities"],
312
+ details={"body_ratio": round(br, 4)}
313
+ ))
314
+
315
+ return results
316
+
317
+
318
+ # ── Multi-Candle Patterns ────────────────────────────────────────────────
319
+
320
+ class MultiCandleDetector:
321
+ """Detect multi-candle patterns (2-5 candle formations)."""
322
+
323
+ @staticmethod
324
+ def detect(df: pd.DataFrame, idx: int, avg_b: float) -> List[PatternResult]:
325
+ results: List[PatternResult] = []
326
+ if idx < 2 or idx >= len(df):
327
+ return results
328
+
329
+ c0 = df.iloc[idx] # current candle
330
+ c1 = df.iloc[idx - 1] # previous candle
331
+ c2 = df.iloc[idx - 2] if idx >= 2 else None
332
+
333
+ o0, h0, l0, close0 = c0["Open"], c0["High"], c0["Low"], c0["Close"]
334
+ o1, h1, l1, close1 = c1["Open"], c1["High"], c1["Low"], c1["Close"]
335
+
336
+ body0 = _body(o0, close0)
337
+ body1 = _body(o1, close1)
338
+ rng0 = _range(h0, l0)
339
+ rng1 = _range(h1, l1)
340
+
341
+ # 1. Bullish Engulfing
342
+ if _is_bearish(o1, close1) and _is_bullish(o0, close0):
343
+ if o0 <= close1 and close0 >= o1 and body0 > body1:
344
+ results.append(PatternResult(
345
+ name="Bullish Engulfing", category="multi", direction="bullish",
346
+ reliability=0.82, index=idx,
347
+ description="Bullish candle completely engulfs prior bearish candle. One of the strongest reversal patterns.",
348
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
349
+ details={"engulfing_ratio": round(body0/body1, 2) if body1 > 0 else 0}
350
+ ))
351
+
352
+ # 2. Bearish Engulfing
353
+ if _is_bullish(o1, close1) and _is_bearish(o0, close0):
354
+ if o0 >= close1 and close0 <= o1 and body0 > body1:
355
+ results.append(PatternResult(
356
+ name="Bearish Engulfing", category="multi", direction="bearish",
357
+ reliability=0.82, index=idx,
358
+ description="Bearish candle completely engulfs prior bullish candle. Strong bearish reversal pattern.",
359
+ trading_styles=["scalping", "intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
360
+ details={"engulfing_ratio": round(body0/body1, 2) if body1 > 0 else 0}
361
+ ))
362
+
363
+ # 3. Bullish Harami
364
+ if _is_bearish(o1, close1) and _is_bullish(o0, close0):
365
+ if o0 >= close1 and close0 <= o1 and body0 < body1 * 0.6:
366
+ results.append(PatternResult(
367
+ name="Bullish Harami", category="multi", direction="bullish",
368
+ reliability=0.62, index=idx,
369
+ description="Small bullish candle contained within prior bearish candle. Potential trend reversal.",
370
+ trading_styles=["scalping", "intraday", "swing", "options", "forex", "crypto"],
371
+ details={"containment_ratio": round(body0/body1, 2) if body1 > 0 else 0}
372
+ ))
373
+
374
+ # 4. Bearish Harami
375
+ if _is_bullish(o1, close1) and _is_bearish(o0, close0):
376
+ if o0 <= close1 and close0 >= o1 and body0 < body1 * 0.6:
377
+ results.append(PatternResult(
378
+ name="Bearish Harami", category="multi", direction="bearish",
379
+ reliability=0.62, index=idx,
380
+ description="Small bearish candle contained within prior bullish candle. Potential trend reversal.",
381
+ trading_styles=["scalping", "intraday", "swing", "options", "forex", "crypto"],
382
+ details={"containment_ratio": round(body0/body1, 2) if body1 > 0 else 0}
383
+ ))
384
+
385
+ # 5. Piercing Line
386
+ if _is_bearish(o1, close1) and _is_bullish(o0, close0):
387
+ mid1 = (o1 + close1) / 2
388
+ if o0 < close1 and close0 > mid1 and close0 < o1:
389
+ results.append(PatternResult(
390
+ name="Piercing Line", category="multi", direction="bullish",
391
+ reliability=0.70, index=idx,
392
+ description="Opens below prior close, closes above prior midpoint. Bullish reversal pattern.",
393
+ trading_styles=["intraday", "swing", "positional", "futures", "forex", "crypto", "commodities"],
394
+ details={"penetration_pct": round((close0 - close1) / body1 * 100, 1) if body1 > 0 else 0}
395
+ ))
396
+
397
+ # 6. Dark Cloud Cover
398
+ if _is_bullish(o1, close1) and _is_bearish(o0, close0):
399
+ mid1 = (o1 + close1) / 2
400
+ if o0 > close1 and close0 < mid1 and close0 > o1:
401
+ results.append(PatternResult(
402
+ name="Dark Cloud Cover", category="multi", direction="bearish",
403
+ reliability=0.70, index=idx,
404
+ description="Opens above prior close, closes below prior midpoint. Bearish reversal signal.",
405
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
406
+ details={"penetration_pct": round((close1 - close0) / body1 * 100, 1) if body1 > 0 else 0}
407
+ ))
408
+
409
+ # 7. Tweezer Top
410
+ if abs(h0 - h1) / (rng1 + 1e-8) < 0.02:
411
+ if _is_bullish(o1, close1) and _is_bearish(o0, close0):
412
+ results.append(PatternResult(
413
+ name="Tweezer Top", category="multi", direction="bearish",
414
+ reliability=0.68, index=idx,
415
+ description="Two candles with matching highs. Resistance confirmed, bearish reversal likely.",
416
+ trading_styles=["intraday", "swing", "options", "futures", "forex", "crypto"],
417
+ details={"high_diff_pct": round(abs(h0 - h1) / h1 * 100, 4) if h1 > 0 else 0}
418
+ ))
419
+
420
+ # 8. Tweezer Bottom
421
+ if abs(l0 - l1) / (rng1 + 1e-8) < 0.02:
422
+ if _is_bearish(o1, close1) and _is_bullish(o0, close0):
423
+ results.append(PatternResult(
424
+ name="Tweezer Bottom", category="multi", direction="bullish",
425
+ reliability=0.68, index=idx,
426
+ description="Two candles with matching lows. Support confirmed, bullish reversal likely.",
427
+ trading_styles=["intraday", "swing", "options", "futures", "forex", "crypto"],
428
+ details={"low_diff_pct": round(abs(l0 - l1) / l1 * 100, 4) if l1 > 0 else 0}
429
+ ))
430
+
431
+ # Three-candle patterns (need c2)
432
+ if c2 is not None:
433
+ o2, h2, l2, close2 = c2["Open"], c2["High"], c2["Low"], c2["Close"]
434
+ body2 = _body(o2, close2)
435
+
436
+ # 9. Morning Star
437
+ if (_is_bearish(o2, close2) and body2 > avg_b * 0.5
438
+ and body1 < avg_b * 0.3 # small middle candle
439
+ and _is_bullish(o0, close0) and body0 > avg_b * 0.5
440
+ and close0 > (o2 + close2) / 2):
441
+ results.append(PatternResult(
442
+ name="Morning Star", category="multi", direction="bullish",
443
+ reliability=0.85, index=idx,
444
+ description="Three-candle reversal: bearish, small indecision, strong bullish. One of the most reliable bullish reversals.",
445
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
446
+ details={"middle_body_ratio": round(body1/avg_b, 2) if avg_b > 0 else 0}
447
+ ))
448
+
449
+ # 10. Evening Star
450
+ if (_is_bullish(o2, close2) and body2 > avg_b * 0.5
451
+ and body1 < avg_b * 0.3
452
+ and _is_bearish(o0, close0) and body0 > avg_b * 0.5
453
+ and close0 < (o2 + close2) / 2):
454
+ results.append(PatternResult(
455
+ name="Evening Star", category="multi", direction="bearish",
456
+ reliability=0.85, index=idx,
457
+ description="Three-candle reversal: bullish, small indecision, strong bearish. One of the most reliable bearish reversals.",
458
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
459
+ details={"middle_body_ratio": round(body1/avg_b, 2) if avg_b > 0 else 0}
460
+ ))
461
+
462
+ # 11. Three White Soldiers
463
+ if (all(_is_bullish(df.iloc[idx-j]["Open"], df.iloc[idx-j]["Close"]) for j in range(3))
464
+ and close0 > close1 > close2
465
+ and all(_body(df.iloc[idx-j]["Open"], df.iloc[idx-j]["Close"]) > avg_b * 0.4 for j in range(3))):
466
+ results.append(PatternResult(
467
+ name="Three White Soldiers", category="multi", direction="bullish",
468
+ reliability=0.80, index=idx,
469
+ description="Three consecutive large bullish candles with higher closes. Strong uptrend continuation.",
470
+ trading_styles=["swing", "positional", "futures", "forex", "crypto", "commodities"],
471
+ details={"avg_body_size": round(np.mean([body0, body1, body2]), 2)}
472
+ ))
473
+
474
+ # 12. Three Black Crows
475
+ if (all(_is_bearish(df.iloc[idx-j]["Open"], df.iloc[idx-j]["Close"]) for j in range(3))
476
+ and close0 < close1 < close2
477
+ and all(_body(df.iloc[idx-j]["Open"], df.iloc[idx-j]["Close"]) > avg_b * 0.4 for j in range(3))):
478
+ results.append(PatternResult(
479
+ name="Three Black Crows", category="multi", direction="bearish",
480
+ reliability=0.80, index=idx,
481
+ description="Three consecutive large bearish candles with lower closes. Strong downtrend continuation.",
482
+ trading_styles=["swing", "positional", "futures", "forex", "crypto", "commodities"],
483
+ details={"avg_body_size": round(np.mean([body0, body1, body2]), 2)}
484
+ ))
485
+
486
+ # 13. Three Inside Up
487
+ if (_is_bearish(o2, close2) and body2 > avg_b * 0.6
488
+ and _is_bullish(o1, close1)
489
+ and o1 >= close2 and close1 <= o2
490
+ and _is_bullish(o0, close0) and close0 > o2):
491
+ results.append(PatternResult(
492
+ name="Three Inside Up", category="multi", direction="bullish",
493
+ reliability=0.76, index=idx,
494
+ description="Harami pattern confirmed by third bullish candle closing above first candle. Strong bullish reversal.",
495
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto"],
496
+ details={}
497
+ ))
498
+
499
+ # 14. Three Inside Down
500
+ if (_is_bullish(o2, close2) and body2 > avg_b * 0.6
501
+ and _is_bearish(o1, close1)
502
+ and o1 <= close2 and close1 >= o2
503
+ and _is_bearish(o0, close0) and close0 < o2):
504
+ results.append(PatternResult(
505
+ name="Three Inside Down", category="multi", direction="bearish",
506
+ reliability=0.76, index=idx,
507
+ description="Harami pattern confirmed by third bearish candle closing below first candle. Strong bearish reversal.",
508
+ trading_styles=["intraday", "swing", "positional", "options", "futures", "forex", "crypto"],
509
+ details={}
510
+ ))
511
+
512
+ # 15. Abandoned Baby (Bullish)
513
+ if (_is_bearish(o2, close2)
514
+ and h1 < l2 and h1 < l0 # gap isolation
515
+ and body1 < avg_b * 0.15
516
+ and _is_bullish(o0, close0)):
517
+ results.append(PatternResult(
518
+ name="Bullish Abandoned Baby", category="multi", direction="bullish",
519
+ reliability=0.88, index=idx,
520
+ description="Isolated doji gapped below prior and next candles. Extremely rare and reliable bullish reversal.",
521
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto"],
522
+ details={}
523
+ ))
524
+
525
+ # 16. Abandoned Baby (Bearish)
526
+ if (_is_bullish(o2, close2)
527
+ and l1 > h2 and l1 > h0
528
+ and body1 < avg_b * 0.15
529
+ and _is_bearish(o0, close0)):
530
+ results.append(PatternResult(
531
+ name="Bearish Abandoned Baby", category="multi", direction="bearish",
532
+ reliability=0.88, index=idx,
533
+ description="Isolated doji gapped above prior and next candles. Extremely rare and reliable bearish reversal.",
534
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto"],
535
+ details={}
536
+ ))
537
+
538
+ return results
539
+
540
+
541
+ # ── Complex Chart Patterns ───────────────────────────────────────────────
542
+
543
+ class ComplexPatternDetector:
544
+ """Detect multi-bar chart patterns using rolling window analysis."""
545
+
546
+ @staticmethod
547
+ def detect(df: pd.DataFrame, window: int = 20) -> List[PatternResult]:
548
+ results: List[PatternResult] = []
549
+ if len(df) < window + 5:
550
+ return results
551
+
552
+ highs = df["High"].values
553
+ lows = df["Low"].values
554
+ closes = df["Close"].values
555
+ n = len(df)
556
+
557
+ # Scan from window onward
558
+ for i in range(window, n):
559
+ segment_h = highs[i - window:i + 1]
560
+ segment_l = lows[i - window:i + 1]
561
+ segment_c = closes[i - window:i + 1]
562
+
563
+ # Double Top
564
+ peaks = []
565
+ for j in range(2, len(segment_h) - 2):
566
+ if segment_h[j] > segment_h[j-1] and segment_h[j] > segment_h[j-2] \
567
+ and segment_h[j] > segment_h[j+1] and segment_h[j] > segment_h[j+2]:
568
+ peaks.append((j, segment_h[j]))
569
+
570
+ if len(peaks) >= 2:
571
+ p1, p2 = peaks[-2], peaks[-1]
572
+ if abs(p1[1] - p2[1]) / p1[1] < 0.02 and p2[0] - p1[0] >= 5:
573
+ trough = min(segment_l[p1[0]:p2[0]+1])
574
+ if segment_c[-1] < trough:
575
+ results.append(PatternResult(
576
+ name="Double Top", category="complex", direction="bearish",
577
+ reliability=0.78, index=i,
578
+ description="Two peaks at similar levels followed by neckline breakdown. Strong bearish reversal.",
579
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
580
+ details={"peak_diff_pct": round(abs(p1[1]-p2[1])/p1[1]*100, 2)}
581
+ ))
582
+
583
+ # Double Bottom
584
+ troughs = []
585
+ for j in range(2, len(segment_l) - 2):
586
+ if segment_l[j] < segment_l[j-1] and segment_l[j] < segment_l[j-2] \
587
+ and segment_l[j] < segment_l[j+1] and segment_l[j] < segment_l[j+2]:
588
+ troughs.append((j, segment_l[j]))
589
+
590
+ if len(troughs) >= 2:
591
+ t1, t2 = troughs[-2], troughs[-1]
592
+ if abs(t1[1] - t2[1]) / t1[1] < 0.02 and t2[0] - t1[0] >= 5:
593
+ peak = max(segment_h[t1[0]:t2[0]+1])
594
+ if segment_c[-1] > peak:
595
+ results.append(PatternResult(
596
+ name="Double Bottom", category="complex", direction="bullish",
597
+ reliability=0.78, index=i,
598
+ description="Two troughs at similar levels followed by neckline breakout. Strong bullish reversal.",
599
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
600
+ details={"trough_diff_pct": round(abs(t1[1]-t2[1])/t1[1]*100, 2)}
601
+ ))
602
+
603
+ # Rising Wedge (bearish)
604
+ if len(segment_c) >= 10:
605
+ upper_slope = np.polyfit(range(len(segment_h)), segment_h, 1)[0]
606
+ lower_slope = np.polyfit(range(len(segment_l)), segment_l, 1)[0]
607
+ if upper_slope > 0 and lower_slope > 0 and lower_slope > upper_slope * 0.5:
608
+ if upper_slope < lower_slope * 1.5: # converging
609
+ results.append(PatternResult(
610
+ name="Rising Wedge", category="complex", direction="bearish",
611
+ reliability=0.72, index=i,
612
+ description="Price making higher highs and higher lows in converging channel. Typically breaks down.",
613
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
614
+ details={"upper_slope": round(upper_slope, 6), "lower_slope": round(lower_slope, 6)}
615
+ ))
616
+
617
+ # Falling Wedge (bullish)
618
+ if len(segment_c) >= 10:
619
+ upper_slope = np.polyfit(range(len(segment_h)), segment_h, 1)[0]
620
+ lower_slope = np.polyfit(range(len(segment_l)), segment_l, 1)[0]
621
+ if upper_slope < 0 and lower_slope < 0 and upper_slope > lower_slope * 0.5:
622
+ if lower_slope < upper_slope * 1.5: # converging
623
+ results.append(PatternResult(
624
+ name="Falling Wedge", category="complex", direction="bullish",
625
+ reliability=0.72, index=i,
626
+ description="Price making lower highs and lower lows in converging channel. Typically breaks upward.",
627
+ trading_styles=["swing", "positional", "options", "futures", "forex", "crypto", "commodities"],
628
+ details={"upper_slope": round(upper_slope, 6), "lower_slope": round(lower_slope, 6)}
629
+ ))
630
+
631
+ # Bull Flag
632
+ if i >= 30:
633
+ pre_flag = closes[i-30:i-10]
634
+ flag_body = closes[i-10:i+1]
635
+ if len(pre_flag) >= 10 and len(flag_body) >= 5:
636
+ pre_ret = (pre_flag[-1] - pre_flag[0]) / pre_flag[0] if pre_flag[0] > 0 else 0
637
+ flag_slope = np.polyfit(range(len(flag_body)), flag_body, 1)[0]
638
+ flag_range = (max(flag_body) - min(flag_body)) / min(flag_body) if min(flag_body) > 0 else 0
639
+ if pre_ret > 0.05 and flag_slope < 0 and flag_range < 0.05:
640
+ results.append(PatternResult(
641
+ name="Bull Flag", category="complex", direction="bullish",
642
+ reliability=0.70, index=i,
643
+ description="Strong upward move (pole) followed by slight downward consolidation (flag). Continuation pattern.",
644
+ trading_styles=["scalping", "intraday", "swing", "options", "futures", "forex", "crypto"],
645
+ details={"pole_return_pct": round(pre_ret * 100, 2), "flag_range_pct": round(flag_range * 100, 2)}
646
+ ))
647
+
648
+ # Bear Flag
649
+ if i >= 30:
650
+ pre_flag = closes[i-30:i-10]
651
+ flag_body = closes[i-10:i+1]
652
+ if len(pre_flag) >= 10 and len(flag_body) >= 5:
653
+ pre_ret = (pre_flag[-1] - pre_flag[0]) / pre_flag[0] if pre_flag[0] > 0 else 0
654
+ flag_slope = np.polyfit(range(len(flag_body)), flag_body, 1)[0]
655
+ flag_range = (max(flag_body) - min(flag_body)) / min(flag_body) if min(flag_body) > 0 else 0
656
+ if pre_ret < -0.05 and flag_slope > 0 and flag_range < 0.05:
657
+ results.append(PatternResult(
658
+ name="Bear Flag", category="complex", direction="bearish",
659
+ reliability=0.70, index=i,
660
+ description="Strong downward move (pole) followed by slight upward consolidation (flag). Bearish continuation.",
661
+ trading_styles=["scalping", "intraday", "swing", "options", "futures", "forex", "crypto"],
662
+ details={"pole_return_pct": round(pre_ret * 100, 2)}
663
+ ))
664
+
665
+ # Deduplicate: keep only the last occurrence of each pattern
666
+ seen = {}
667
+ for r in results:
668
+ seen[r.name] = r
669
+ return list(seen.values())
670
+
671
+
672
+ # ── Master Detector ──────────────────────────────────────────────────────
673
+
674
+ class CandlestickPatternDetector:
675
+ """
676
+ Master pattern detector — runs all sub-detectors and returns
677
+ a unified list of detected patterns sorted by reliability.
678
+ """
679
+
680
+ def __init__(self):
681
+ self.single = SingleCandleDetector()
682
+ self.multi = MultiCandleDetector()
683
+ self.complex = ComplexPatternDetector()
684
+
685
+ def detect_all(
686
+ self,
687
+ df: pd.DataFrame,
688
+ lookback: int = 5,
689
+ ) -> List[Dict[str, Any]]:
690
+ """
691
+ Detect all patterns in the most recent `lookback` candles.
692
+
693
+ Returns list of pattern dicts sorted by reliability (highest first).
694
+ """
695
+ if df.empty or len(df) < 5:
696
+ return []
697
+
698
+ all_patterns: List[PatternResult] = []
699
+ n = len(df)
700
+ start = max(0, n - lookback)
701
+
702
+ for idx in range(start, n):
703
+ avg_b = _avg_body(df, idx)
704
+
705
+ # Single-candle
706
+ all_patterns.extend(self.single.detect(df, idx, avg_b))
707
+ # Multi-candle
708
+ all_patterns.extend(self.multi.detect(df, idx, avg_b))
709
+
710
+ # Complex chart patterns (use wider window)
711
+ all_patterns.extend(self.complex.detect(df, window=min(30, len(df) - 5)))
712
+
713
+ # Deduplicate by name (keep highest reliability instance)
714
+ best: Dict[str, PatternResult] = {}
715
+ for p in all_patterns:
716
+ if p.name not in best or p.reliability > best[p.name].reliability:
717
+ best[p.name] = p
718
+
719
+ # Sort by reliability descending
720
+ sorted_patterns = sorted(best.values(), key=lambda x: x.reliability, reverse=True)
721
+
722
+ return [p.to_dict() for p in sorted_patterns]
723
+
724
+ def get_pattern_catalog(self) -> List[Dict[str, Any]]:
725
+ """Return catalog of all 35+ supported patterns."""
726
+ catalog = [
727
+ # Single-candle (12)
728
+ {"name": "Doji", "category": "single", "direction": "neutral", "reliability": 0.55, "description": "Open equals close. Market indecision signal."},
729
+ {"name": "Dragonfly Doji", "category": "single", "direction": "bullish", "reliability": 0.72, "description": "Open/close at high with long lower shadow. Bullish reversal."},
730
+ {"name": "Gravestone Doji", "category": "single", "direction": "bearish", "reliability": 0.71, "description": "Open/close at low with long upper shadow. Bearish reversal."},
731
+ {"name": "Long-Legged Doji", "category": "single", "direction": "neutral", "reliability": 0.60, "description": "Equal long shadows. Extreme indecision."},
732
+ {"name": "Hammer", "category": "single", "direction": "bullish", "reliability": 0.75, "description": "Small body at top, long lower shadow. Classic bullish reversal."},
733
+ {"name": "Hanging Man", "category": "single", "direction": "bearish", "reliability": 0.65, "description": "Hammer at top of uptrend. Bearish warning."},
734
+ {"name": "Inverted Hammer", "category": "single", "direction": "bullish", "reliability": 0.65, "description": "Small body at bottom, long upper shadow. Bullish reversal."},
735
+ {"name": "Shooting Star", "category": "single", "direction": "bearish", "reliability": 0.72, "description": "Small body near low, long upper shadow. Bearish reversal."},
736
+ {"name": "Bullish Marubozu", "category": "single", "direction": "bullish", "reliability": 0.78, "description": "Full bullish body, no shadows. Strong buying pressure."},
737
+ {"name": "Bearish Marubozu", "category": "single", "direction": "bearish", "reliability": 0.78, "description": "Full bearish body, no shadows. Strong selling pressure."},
738
+ {"name": "Spinning Top", "category": "single", "direction": "neutral", "reliability": 0.45, "description": "Small body, shadows on both sides. Indecision."},
739
+ {"name": "High Wave Candle", "category": "single", "direction": "neutral", "reliability": 0.55, "description": "Tiny body, very long shadows. Major indecision."},
740
+ {"name": "Bullish Belt Hold", "category": "single", "direction": "bullish", "reliability": 0.68, "description": "Opens at low, strong close near high."},
741
+ {"name": "Bearish Belt Hold", "category": "single", "direction": "bearish", "reliability": 0.68, "description": "Opens at high, closes near low."},
742
+ # Multi-candle (16)
743
+ {"name": "Bullish Engulfing", "category": "multi", "direction": "bullish", "reliability": 0.82, "description": "Bullish candle engulfs prior bearish. Strong reversal."},
744
+ {"name": "Bearish Engulfing", "category": "multi", "direction": "bearish", "reliability": 0.82, "description": "Bearish candle engulfs prior bullish. Strong reversal."},
745
+ {"name": "Bullish Harami", "category": "multi", "direction": "bullish", "reliability": 0.62, "description": "Small bullish inside prior bearish. Reversal."},
746
+ {"name": "Bearish Harami", "category": "multi", "direction": "bearish", "reliability": 0.62, "description": "Small bearish inside prior bullish. Reversal."},
747
+ {"name": "Piercing Line", "category": "multi", "direction": "bullish", "reliability": 0.70, "description": "Opens below, closes above prior midpoint."},
748
+ {"name": "Dark Cloud Cover", "category": "multi", "direction": "bearish", "reliability": 0.70, "description": "Opens above, closes below prior midpoint."},
749
+ {"name": "Tweezer Top", "category": "multi", "direction": "bearish", "reliability": 0.68, "description": "Matching highs confirm resistance."},
750
+ {"name": "Tweezer Bottom", "category": "multi", "direction": "bullish", "reliability": 0.68, "description": "Matching lows confirm support."},
751
+ {"name": "Morning Star", "category": "multi", "direction": "bullish", "reliability": 0.85, "description": "Three-candle bullish reversal. Highly reliable."},
752
+ {"name": "Evening Star", "category": "multi", "direction": "bearish", "reliability": 0.85, "description": "Three-candle bearish reversal. Highly reliable."},
753
+ {"name": "Three White Soldiers", "category": "multi", "direction": "bullish", "reliability": 0.80, "description": "Three strong bullish candles. Uptrend continuation."},
754
+ {"name": "Three Black Crows", "category": "multi", "direction": "bearish", "reliability": 0.80, "description": "Three strong bearish candles. Downtrend continuation."},
755
+ {"name": "Three Inside Up", "category": "multi", "direction": "bullish", "reliability": 0.76, "description": "Confirmed harami breakout. Bullish."},
756
+ {"name": "Three Inside Down", "category": "multi", "direction": "bearish", "reliability": 0.76, "description": "Confirmed harami breakdown. Bearish."},
757
+ {"name": "Bullish Abandoned Baby", "category": "multi", "direction": "bullish", "reliability": 0.88, "description": "Isolated doji gap. Extremely rare, very bullish."},
758
+ {"name": "Bearish Abandoned Baby", "category": "multi", "direction": "bearish", "reliability": 0.88, "description": "Isolated doji gap. Extremely rare, very bearish."},
759
+ # Complex (6)
760
+ {"name": "Double Top", "category": "complex", "direction": "bearish", "reliability": 0.78, "description": "Two peaks at same level, neckline breakdown."},
761
+ {"name": "Double Bottom", "category": "complex", "direction": "bullish", "reliability": 0.78, "description": "Two troughs at same level, neckline breakout."},
762
+ {"name": "Rising Wedge", "category": "complex", "direction": "bearish", "reliability": 0.72, "description": "Converging uptrend channel. Typically breaks down."},
763
+ {"name": "Falling Wedge", "category": "complex", "direction": "bullish", "reliability": 0.72, "description": "Converging downtrend channel. Typically breaks up."},
764
+ {"name": "Bull Flag", "category": "complex", "direction": "bullish", "reliability": 0.70, "description": "Strong up-move then slight consolidation. Continuation."},
765
+ {"name": "Bear Flag", "category": "complex", "direction": "bearish", "reliability": 0.70, "description": "Strong down-move then slight consolidation. Continuation."},
766
+ ]
767
+ return catalog
768
+
769
+
770
+ # Module singleton
771
+ pattern_detector = CandlestickPatternDetector()
backend/app/services/ml/pattern_recognition/predictor.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LightGBM Ensemble Predictor for Pattern-Based Trading Signals.
3
+
4
+ Combines candlestick patterns + advanced math features + standard technicals
5
+ into a single LightGBM model for multi-class prediction:
6
+ - Class 0: Strong Down (< -1% expected return)
7
+ - Class 1: Neutral (-1% to +1%)
8
+ - Class 2: Strong Up (> +1%)
9
+
10
+ Design:
11
+ - Uses LightGBM (fastest boosting library, 10x lighter than XGBoost)
12
+ - Walk-forward validation (no lookahead bias)
13
+ - Feature importance via built-in gain importance + optional SHAP
14
+ - In-memory model cache with 8-hour TTL
15
+ - Supports all markets: equities, crypto, forex, commodities
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # ── Cache ────────────────────────────────────────────────────────────────
31
+
32
+ CACHE_TTL = 8 * 3600 # 8 hours
33
+
34
+
35
+ @dataclass
36
+ class CachedPredictor:
37
+ model: Any
38
+ feature_names: List[str]
39
+ metrics: Dict[str, float]
40
+ trained_at: float = field(default_factory=time.time)
41
+
42
+ @property
43
+ def is_stale(self) -> bool:
44
+ return (time.time() - self.trained_at) > CACHE_TTL
45
+
46
+
47
+ _predictor_cache: Dict[str, CachedPredictor] = {}
48
+
49
+
50
+ # ── Feature Assembly ─────────────────────────────────────────────────────
51
+
52
+ def _assemble_features(df: pd.DataFrame) -> pd.DataFrame:
53
+ """
54
+ Assemble the complete feature matrix from:
55
+ 1. Standard technical indicators (from feature_engineering pipeline)
56
+ 2. Pattern detection scores
57
+ 3. Advanced mathematical features
58
+ """
59
+ from app.services.feature_engineering.pipeline import feature_pipeline
60
+ from app.services.ml.pattern_recognition.pattern_detector import pattern_detector
61
+ from app.services.ml.pattern_recognition.advanced_features import advanced_feature_engine
62
+
63
+ # 1. Standard technicals
64
+ featured = feature_pipeline.compute_all_features(df)
65
+
66
+ # 2. Pattern features (serialize as numeric)
67
+ n = len(featured)
68
+ bullish_count = np.zeros(n)
69
+ bearish_count = np.zeros(n)
70
+ max_reliability = np.zeros(n)
71
+ pattern_signal = np.zeros(n) # +1 bullish, -1 bearish, weighted by reliability
72
+
73
+ for i in range(max(0, n - 30), n):
74
+ if i < 5:
75
+ continue
76
+ # Detect on a small slice
77
+ slice_df = df.iloc[max(0, i-20):i+1].copy()
78
+ if len(slice_df) < 5:
79
+ continue
80
+ patterns = pattern_detector.detect_all(slice_df, lookback=3)
81
+ for p in patterns:
82
+ rel = p.get("reliability", 0.5)
83
+ direction = p.get("direction", "neutral")
84
+ if direction == "bullish":
85
+ bullish_count[i] += 1
86
+ pattern_signal[i] += rel
87
+ elif direction == "bearish":
88
+ bearish_count[i] += 1
89
+ pattern_signal[i] -= rel
90
+ max_reliability[i] = max(max_reliability[i], rel)
91
+
92
+ featured["pattern_bullish_count"] = bullish_count
93
+ featured["pattern_bearish_count"] = bearish_count
94
+ featured["pattern_max_reliability"] = max_reliability
95
+ featured["pattern_signal"] = pattern_signal
96
+
97
+ # 3. Advanced math features (rolling)
98
+ adv = advanced_feature_engine.compute_feature_series(df, window=20)
99
+ for col in ["hurst_exponent", "fractal_dimension", "entropy",
100
+ "price_efficiency", "trend_strength",
101
+ "return_skew_20", "return_kurtosis_20"]:
102
+ if col in adv.columns:
103
+ featured[col] = adv[col]
104
+
105
+ # 4. Additional return-based features
106
+ close = featured["Close"]
107
+ for lag in [1, 2, 3, 5, 10]:
108
+ featured[f"return_{lag}d"] = close.pct_change(lag)
109
+
110
+ log_ret = np.log(close / close.shift(1))
111
+ for w in [5, 10, 20]:
112
+ featured[f"vol_{w}d"] = log_ret.rolling(w).std() * np.sqrt(252)
113
+
114
+ for ma_col in ["sma_20", "sma_50", "sma_200"]:
115
+ if ma_col in featured.columns:
116
+ featured[f"price_vs_{ma_col}"] = (close - featured[ma_col]) / (featured[ma_col] + 1e-10)
117
+
118
+ if "Volume" in featured.columns:
119
+ featured["volume_change"] = featured["Volume"].pct_change()
120
+ featured["volume_zscore"] = (
121
+ featured["Volume"] - featured["Volume"].rolling(20).mean()
122
+ ) / (featured["Volume"].rolling(20).std() + 1e-10)
123
+
124
+ # Day of week
125
+ if hasattr(featured.index, "dayofweek"):
126
+ featured["day_of_week"] = featured.index.dayofweek
127
+
128
+ return featured
129
+
130
+
131
+ def _create_target(df: pd.DataFrame, horizon: int = 5, threshold: float = 0.01) -> pd.Series:
132
+ """
133
+ Multi-class target:
134
+ 0 = strong down (return < -threshold)
135
+ 1 = neutral
136
+ 2 = strong up (return > threshold)
137
+ """
138
+ future_return = df["Close"].shift(-horizon) / df["Close"] - 1
139
+ target = pd.Series(1, index=df.index) # neutral by default
140
+ target[future_return > threshold] = 2 # strong up
141
+ target[future_return < -threshold] = 0 # strong down
142
+ return target
143
+
144
+
145
+ # ── Training ─────────────────────────────────────────────────────────────
146
+
147
+ def _train_model(
148
+ df: pd.DataFrame,
149
+ horizon: int = 5,
150
+ threshold: float = 0.01,
151
+ ) -> Tuple[Any, List[str], Dict[str, float]]:
152
+ """
153
+ Train LightGBM classifier with walk-forward validation.
154
+ """
155
+ from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
156
+ import lightgbm as lgb
157
+
158
+ featured = _assemble_features(df)
159
+ featured["target"] = _create_target(featured, horizon=horizon, threshold=threshold)
160
+ featured = featured.dropna()
161
+
162
+ if len(featured) < 100:
163
+ raise ValueError(f"Insufficient data: {len(featured)} rows (need 100+)")
164
+
165
+ # Feature columns
166
+ exclude = {"Open", "High", "Low", "Close", "Volume", "Adj Close", "target"}
167
+ feature_cols = [
168
+ c for c in featured.columns
169
+ if c not in exclude and featured[c].dtype in [np.float64, np.int64, np.float32, np.int32]
170
+ ]
171
+
172
+ X = featured[feature_cols].values
173
+ y = featured["target"].values
174
+
175
+ # Walk-forward split
176
+ split = int(len(X) * 0.8)
177
+ X_train, X_val = X[:split], X[split:]
178
+ y_train, y_val = y[:split], y[split:]
179
+
180
+ X_train = np.nan_to_num(X_train, nan=0.0, posinf=0.0, neginf=0.0)
181
+ X_val = np.nan_to_num(X_val, nan=0.0, posinf=0.0, neginf=0.0)
182
+
183
+ # Class weights for imbalanced data
184
+ classes, counts = np.unique(y_train, return_counts=True)
185
+ total = len(y_train)
186
+ class_weights = {int(c): total / (len(classes) * cnt) for c, cnt in zip(classes, counts)}
187
+
188
+ train_data = lgb.Dataset(X_train, label=y_train)
189
+ val_data = lgb.Dataset(X_val, label=y_val, reference=train_data)
190
+
191
+ params = {
192
+ "objective": "multiclass",
193
+ "num_class": 3,
194
+ "metric": "multi_logloss",
195
+ "boosting_type": "gbdt",
196
+ "num_leaves": 63,
197
+ "learning_rate": 0.05,
198
+ "feature_fraction": 0.8,
199
+ "bagging_fraction": 0.8,
200
+ "bagging_freq": 5,
201
+ "lambda_l1": 0.1,
202
+ "lambda_l2": 1.0,
203
+ "min_child_samples": 20,
204
+ "verbose": -1,
205
+ "n_jobs": -1,
206
+ "seed": 42,
207
+ }
208
+
209
+ callbacks = [
210
+ lgb.early_stopping(stopping_rounds=20),
211
+ lgb.log_evaluation(period=0),
212
+ ]
213
+
214
+ model = lgb.train(
215
+ params,
216
+ train_data,
217
+ num_boost_round=300,
218
+ valid_sets=[val_data],
219
+ callbacks=callbacks,
220
+ )
221
+
222
+ # Validation metrics
223
+ y_pred_proba = model.predict(X_val)
224
+ y_pred = np.argmax(y_pred_proba, axis=1)
225
+
226
+ metrics = {
227
+ "accuracy": round(float(accuracy_score(y_val, y_pred)), 4),
228
+ "precision": round(float(precision_score(y_val, y_pred, average="weighted", zero_division=0)), 4),
229
+ "recall": round(float(recall_score(y_val, y_pred, average="weighted", zero_division=0)), 4),
230
+ "f1": round(float(f1_score(y_val, y_pred, average="weighted", zero_division=0)), 4),
231
+ "train_samples": len(X_train),
232
+ "val_samples": len(X_val),
233
+ }
234
+
235
+ logger.info(
236
+ "LightGBM trained: %d samples, accuracy=%.1f%%, f1=%.4f",
237
+ len(X_train) + len(X_val), metrics["accuracy"] * 100, metrics["f1"],
238
+ )
239
+
240
+ return model, feature_cols, metrics
241
+
242
+
243
+ # ── Prediction ───────────────────────────────────────────────────────────
244
+
245
+ DIRECTION_MAP = {0: "strong_down", 1: "neutral", 2: "strong_up"}
246
+ DIRECTION_LABELS = {
247
+ "strong_down": "Strong Bearish",
248
+ "neutral": "Neutral",
249
+ "strong_up": "Strong Bullish",
250
+ }
251
+
252
+
253
+ async def predict_with_patterns(
254
+ ticker: str,
255
+ period: str = "2y",
256
+ horizon: int = 5,
257
+ ) -> Dict[str, Any]:
258
+ """
259
+ Full prediction pipeline: patterns + advanced features + LightGBM.
260
+
261
+ Returns prediction direction, confidence, feature importances,
262
+ detected patterns, and model metrics.
263
+ """
264
+ import asyncio
265
+ from app.services.data_ingestion.yahoo import yahoo_adapter
266
+ from app.services.ml.pattern_recognition.pattern_detector import pattern_detector
267
+ from app.services.ml.pattern_recognition.advanced_features import advanced_feature_engine
268
+
269
+ cache_key = f"pattern_{ticker}_{period}_{horizon}"
270
+
271
+ # Fetch data
272
+ df = pd.DataFrame()
273
+ for attempt in range(3):
274
+ try:
275
+ df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
276
+ if not df.empty:
277
+ break
278
+ except Exception as e:
279
+ logger.warning("Fetch attempt %d for %s: %s", attempt + 1, ticker, e)
280
+ if attempt < 2:
281
+ await asyncio.sleep(1)
282
+
283
+ if df.empty or len(df) < 100:
284
+ raise ValueError(f"Insufficient price data for {ticker}")
285
+
286
+ # Check cache
287
+ cached = _predictor_cache.get(cache_key)
288
+ from_cache = False
289
+
290
+ if cached and not cached.is_stale:
291
+ model = cached.model
292
+ feature_names = cached.feature_names
293
+ metrics = cached.metrics
294
+ from_cache = True
295
+ else:
296
+ model, feature_names, metrics = _train_model(df, horizon=horizon)
297
+ _predictor_cache[cache_key] = CachedPredictor(
298
+ model=model, feature_names=feature_names, metrics=metrics,
299
+ )
300
+
301
+ # Build features for prediction
302
+ featured = _assemble_features(df)
303
+ featured = featured.dropna(subset=[c for c in feature_names if c in featured.columns])
304
+
305
+ if featured.empty:
306
+ raise ValueError(f"No valid features for {ticker}")
307
+
308
+ # Latest prediction
309
+ X_latest = featured[feature_names].iloc[[-1]].values
310
+ X_latest = np.nan_to_num(X_latest, nan=0.0, posinf=0.0, neginf=0.0)
311
+
312
+ proba = model.predict(X_latest)[0]
313
+ pred_class = int(np.argmax(proba))
314
+ confidence = float(proba[pred_class])
315
+ direction = DIRECTION_MAP.get(pred_class, "neutral")
316
+
317
+ # Feature importance (top 15)
318
+ importances = model.feature_importance(importance_type="gain")
319
+ importance_pairs = sorted(
320
+ zip(feature_names, importances.tolist()),
321
+ key=lambda x: x[1], reverse=True,
322
+ )[:15]
323
+
324
+ # Detect current patterns
325
+ patterns = pattern_detector.detect_all(df, lookback=5)
326
+
327
+ # Advanced features snapshot
328
+ adv_features = advanced_feature_engine.compute_all(df)
329
+
330
+ # Expected return estimate
331
+ recent = df["Close"].pct_change(horizon).dropna()
332
+ bins = {"strong_up": recent[recent > 0.01], "neutral": recent[abs(recent) <= 0.01], "strong_down": recent[recent < -0.01]}
333
+ expected_return = float(bins.get(direction, recent).mean()) if len(bins.get(direction, recent)) > 0 else 0
334
+
335
+ # Hedge recommendation (aggregate from ML + patterns)
336
+ hedge_signals = [p.get("hedge_signal", "hold_hedge") for p in patterns[:10]]
337
+ bearish_signals = sum(1 for s in hedge_signals if s in ("hedge_now", "increase_hedge"))
338
+ bullish_signals = sum(1 for s in hedge_signals if s == "reduce_hedge")
339
+ pattern_consensus = "bearish" if bearish_signals > bullish_signals else "bullish" if bullish_signals > bearish_signals else "neutral"
340
+
341
+ if direction == "strong_down" and confidence > 0.55:
342
+ hedge_action = "hedge_now"
343
+ hedge_pct = min(50, int(confidence * 65))
344
+ hedge_urgency = "high"
345
+ elif direction == "strong_down" or pattern_consensus == "bearish":
346
+ hedge_action = "increase_hedge"
347
+ hedge_pct = min(35, int(confidence * 45))
348
+ hedge_urgency = "medium"
349
+ elif direction == "strong_up" and confidence > 0.55:
350
+ hedge_action = "reduce_hedge"
351
+ hedge_pct = max(5, int((1 - confidence) * 20))
352
+ hedge_urgency = "low"
353
+ else:
354
+ hedge_action = "hold_hedge"
355
+ hedge_pct = 15
356
+ hedge_urgency = "none"
357
+
358
+ hedge_recommendation = {
359
+ "action": hedge_action,
360
+ "suggested_hedge_pct": hedge_pct,
361
+ "urgency": hedge_urgency,
362
+ "pattern_consensus": pattern_consensus,
363
+ "bearish_pattern_count": bearish_signals,
364
+ "bullish_pattern_count": bullish_signals,
365
+ "rationale": {
366
+ "hedge_now": f"ML predicts {DIRECTION_LABELS.get(direction, direction)} ({confidence:.0%} confidence) with {bearish_signals} bearish pattern(s). Recommend hedging {hedge_pct}% of portfolio via inverse ETFs or protective puts.",
367
+ "increase_hedge": f"Moderate downside risk detected. ML confidence: {confidence:.0%}. Consider increasing hedge exposure to {hedge_pct}%.",
368
+ "reduce_hedge": f"Bullish outlook ({confidence:.0%} confidence). Consider reducing hedge to {hedge_pct}% maintenance level to capture upside.",
369
+ "hold_hedge": f"Mixed or neutral signals. Maintain current hedge allocation (~{hedge_pct}%).",
370
+ }.get(hedge_action, ""),
371
+ "instruments": {
372
+ "hedge_now": ["SH (ProShares Short S&P500)", "SQQQ (ProShares Ultra Short QQQ)", "VIX Calls"],
373
+ "increase_hedge": ["SH (Short S&P500)", "GLD (Gold ETF)", "TLT (Long-Term Treasury)"],
374
+ "reduce_hedge": [],
375
+ "hold_hedge": ["GLD (Gold ETF)"],
376
+ }.get(hedge_action, []),
377
+ }
378
+
379
+ return {
380
+ "ticker": ticker,
381
+ "prediction": direction,
382
+ "prediction_label": DIRECTION_LABELS.get(direction, direction),
383
+ "confidence": round(confidence, 4),
384
+ "probabilities": {
385
+ "strong_down": round(float(proba[0]), 4),
386
+ "neutral": round(float(proba[1]), 4),
387
+ "strong_up": round(float(proba[2]), 4),
388
+ },
389
+ "expected_return_pct": round(expected_return * 100, 2),
390
+ "horizon_days": horizon,
391
+ "confidence_level": (
392
+ "high" if confidence > 0.65
393
+ else "medium" if confidence > 0.45
394
+ else "low"
395
+ ),
396
+ "hedge_recommendation": hedge_recommendation,
397
+ "detected_patterns": patterns[:10],
398
+ "top_features": [
399
+ {"name": name, "importance": round(imp, 4)}
400
+ for name, imp in importance_pairs
401
+ ],
402
+ "advanced_features": {
403
+ k: v for k, v in adv_features.items()
404
+ if k != "error" and not isinstance(v, (list, dict))
405
+ },
406
+ "model_metrics": metrics,
407
+ "from_cache": from_cache,
408
+ "training_samples": len(df),
409
+ "current_price": round(float(df["Close"].iloc[-1]), 2),
410
+ }
411
+
412
+
413
+ async def analyze_multiple(
414
+ tickers: List[str],
415
+ period: str = "2y",
416
+ horizon: int = 5,
417
+ ) -> Dict[str, Any]:
418
+ """Analyze multiple tickers and return comparative results."""
419
+ results = {}
420
+ for ticker in tickers[:10]: # cap at 10
421
+ try:
422
+ results[ticker] = await predict_with_patterns(ticker, period, horizon)
423
+ except Exception as e:
424
+ logger.warning("Analysis failed for %s: %s", ticker, e)
425
+ results[ticker] = {"error": str(e)}
426
+ return results
427
+
428
+
429
+ async def backtest_pattern_accuracy(
430
+ ticker: str,
431
+ period: str = "5y",
432
+ horizon: int = 5,
433
+ ) -> Dict[str, Any]:
434
+ """
435
+ Backtest pattern detection accuracy on historical data.
436
+ For each pattern type, compute win rate and average return.
437
+ """
438
+ from app.services.data_ingestion.yahoo import yahoo_adapter
439
+ from app.services.ml.pattern_recognition.pattern_detector import pattern_detector
440
+
441
+ df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
442
+ if df.empty or len(df) < 100:
443
+ raise ValueError(f"Insufficient data for {ticker}")
444
+
445
+ close = df["Close"].values
446
+ n = len(df)
447
+ pattern_stats: Dict[str, Dict[str, Any]] = {}
448
+
449
+ for i in range(30, n - horizon):
450
+ slice_df = df.iloc[max(0, i-20):i+1].copy()
451
+ patterns = pattern_detector.detect_all(slice_df, lookback=3)
452
+
453
+ future_return = (close[i + horizon] - close[i]) / close[i] if close[i] > 0 else 0
454
+
455
+ for p in patterns:
456
+ name = p["name"]
457
+ direction = p["direction"]
458
+
459
+ if name not in pattern_stats:
460
+ pattern_stats[name] = {
461
+ "occurrences": 0,
462
+ "correct": 0,
463
+ "returns": [],
464
+ "direction": direction,
465
+ "reliability": p["reliability"],
466
+ }
467
+
468
+ stats = pattern_stats[name]
469
+ stats["occurrences"] += 1
470
+ stats["returns"].append(future_return)
471
+
472
+ # Correct if: bullish + positive return, or bearish + negative return
473
+ if (direction == "bullish" and future_return > 0) or \
474
+ (direction == "bearish" and future_return < 0):
475
+ stats["correct"] += 1
476
+
477
+ # Compile results
478
+ accuracy_report = []
479
+ for name, stats in pattern_stats.items():
480
+ occ = stats["occurrences"]
481
+ if occ < 3:
482
+ continue
483
+ avg_return = float(np.mean(stats["returns"])) * 100
484
+ win_rate = stats["correct"] / occ
485
+
486
+ accuracy_report.append({
487
+ "pattern": name,
488
+ "direction": stats["direction"],
489
+ "occurrences": occ,
490
+ "win_rate": round(win_rate, 4),
491
+ "avg_return_pct": round(avg_return, 4),
492
+ "theoretical_reliability": stats["reliability"],
493
+ "actual_vs_theoretical": round(win_rate - stats["reliability"], 4),
494
+ })
495
+
496
+ accuracy_report.sort(key=lambda x: x["win_rate"], reverse=True)
497
+
498
+ return {
499
+ "ticker": ticker,
500
+ "period": period,
501
+ "horizon_days": horizon,
502
+ "total_bars_analyzed": n - 30 - horizon,
503
+ "patterns_found": len(accuracy_report),
504
+ "accuracy_report": accuracy_report,
505
+ }
506
+
507
+
508
+ def clear_cache(ticker: Optional[str] = None) -> int:
509
+ """Clear predictor cache."""
510
+ if ticker:
511
+ keys = [k for k in _predictor_cache if ticker in k]
512
+ for k in keys:
513
+ del _predictor_cache[k]
514
+ return len(keys)
515
+ count = len(_predictor_cache)
516
+ _predictor_cache.clear()
517
+ return count
backend/app/services/pinescript/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Pine Script Auto-Generation Lab
backend/app/services/pinescript/generator.py ADDED
@@ -0,0 +1,855 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pine Script v5 Strategy Generator.
3
+
4
+ Generates production-ready TradingView Pine Script v5 code from:
5
+ 1. Natural language descriptions (via LLM)
6
+ 2. Pre-built strategy templates
7
+ 3. Custom parameter configurations
8
+
9
+ Supports 12+ built-in templates covering all strategy types:
10
+ - Trend following, mean reversion, momentum, breakout
11
+ - Multi-timeframe, oscillator, volatility-based
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ import aiohttp
20
+
21
+ from app.config import get_settings
22
+
23
+ logger = logging.getLogger(__name__)
24
+ _settings = get_settings()
25
+
26
+
27
+ # ── Strategy Templates ───────────────────────────────────────────────────
28
+
29
+ STRATEGY_TEMPLATES: Dict[str, Dict[str, Any]] = {
30
+ "sma_crossover": {
31
+ "id": "sma_crossover",
32
+ "name": "SMA Crossover",
33
+ "category": "Momentum",
34
+ "description": "Classic dual moving average crossover strategy. Buys on golden cross, sells on death cross.",
35
+ "parameters": {"fast_length": 20, "slow_length": 50},
36
+ "code": '''
37
+ //@version=5
38
+ strategy("SMA Crossover Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
39
+
40
+ // Inputs
41
+ fast_length = input.int({fast_length}, title="Fast SMA Length", minval=1)
42
+ slow_length = input.int({slow_length}, title="Slow SMA Length", minval=1)
43
+ use_stop_loss = input.bool(true, title="Use Stop Loss")
44
+ stop_loss_pct = input.float(5.0, title="Stop Loss %", minval=0.1, step=0.1)
45
+ use_take_profit = input.bool(true, title="Use Take Profit")
46
+ take_profit_pct = input.float(10.0, title="Take Profit %", minval=0.1, step=0.1)
47
+
48
+ // Calculations
49
+ fast_sma = ta.sma(close, fast_length)
50
+ slow_sma = ta.sma(close, slow_length)
51
+
52
+ // Conditions
53
+ long_condition = ta.crossover(fast_sma, slow_sma)
54
+ short_condition = ta.crossunder(fast_sma, slow_sma)
55
+
56
+ // Strategy execution
57
+ if long_condition
58
+ strategy.entry("Long", strategy.long)
59
+ if short_condition
60
+ strategy.close("Long")
61
+
62
+ // Risk management
63
+ if use_stop_loss or use_take_profit
64
+ strategy.exit("Exit", "Long", stop=use_stop_loss ? strategy.position_avg_price * (1 - stop_loss_pct / 100) : na, limit=use_take_profit ? strategy.position_avg_price * (1 + take_profit_pct / 100) : na)
65
+
66
+ // Plotting
67
+ plot(fast_sma, color=color.new(color.blue, 0), title="Fast SMA", linewidth=2)
68
+ plot(slow_sma, color=color.new(color.red, 0), title="Slow SMA", linewidth=2)
69
+ bgcolor(strategy.position_size > 0 ? color.new(color.green, 90) : na)
70
+ ''',
71
+ },
72
+
73
+ "rsi_reversal": {
74
+ "id": "rsi_reversal",
75
+ "name": "RSI Mean Reversion",
76
+ "category": "Mean Reversion",
77
+ "description": "Buys when RSI is oversold, sells when overbought. Classic counter-trend strategy.",
78
+ "parameters": {"rsi_length": 14, "oversold": 30, "overbought": 70},
79
+ "code": '''
80
+ //@version=5
81
+ strategy("RSI Mean Reversion", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
82
+
83
+ // Inputs
84
+ rsi_length = input.int({rsi_length}, title="RSI Length", minval=2)
85
+ oversold_level = input.int({oversold}, title="Oversold Level", minval=1, maxval=50)
86
+ overbought_level = input.int({overbought}, title="Overbought Level", minval=50, maxval=99)
87
+ stop_loss_pct = input.float(3.0, title="Stop Loss %", minval=0.1, step=0.1)
88
+ take_profit_pct = input.float(6.0, title="Take Profit %", minval=0.1, step=0.1)
89
+
90
+ // Calculations
91
+ rsi_val = ta.rsi(close, rsi_length)
92
+
93
+ // Conditions
94
+ long_condition = ta.crossover(rsi_val, oversold_level)
95
+ exit_condition = ta.crossunder(rsi_val, overbought_level)
96
+
97
+ // Execution
98
+ if long_condition
99
+ strategy.entry("Long", strategy.long)
100
+ if exit_condition
101
+ strategy.close("Long")
102
+
103
+ strategy.exit("SL/TP", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100), limit=strategy.position_avg_price * (1 + take_profit_pct / 100))
104
+
105
+ // Plot
106
+ hline(oversold_level, color=color.green, linestyle=hline.style_dashed)
107
+ hline(overbought_level, color=color.red, linestyle=hline.style_dashed)
108
+ ''',
109
+ },
110
+
111
+ "macd_signal": {
112
+ "id": "macd_signal",
113
+ "name": "MACD Signal Line Crossover",
114
+ "category": "Momentum",
115
+ "description": "Buys on MACD bullish crossover, closes on bearish crossover with histogram confirmation.",
116
+ "parameters": {"fast": 12, "slow": 26, "signal": 9},
117
+ "code": '''
118
+ //@version=5
119
+ strategy("MACD Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
120
+
121
+ fast_len = input.int({fast}, title="MACD Fast Length")
122
+ slow_len = input.int({slow}, title="MACD Slow Length")
123
+ sig_len = input.int({signal}, title="Signal Length")
124
+ stop_loss_pct = input.float(4.0, title="Stop Loss %")
125
+ take_profit_pct = input.float(8.0, title="Take Profit %")
126
+
127
+ [macdLine, signalLine, hist] = ta.macd(close, fast_len, slow_len, sig_len)
128
+
129
+ long_cond = ta.crossover(macdLine, signalLine) and hist > 0
130
+ exit_cond = ta.crossunder(macdLine, signalLine)
131
+
132
+ if long_cond
133
+ strategy.entry("Long", strategy.long)
134
+ if exit_cond
135
+ strategy.close("Long")
136
+
137
+ strategy.exit("Exit", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100), limit=strategy.position_avg_price * (1 + take_profit_pct / 100))
138
+ ''',
139
+ },
140
+
141
+ "bollinger_breakout": {
142
+ "id": "bollinger_breakout",
143
+ "name": "Bollinger Band Breakout",
144
+ "category": "Volatility",
145
+ "description": "Buys when price breaks above upper band, sells on return to middle band. Captures volatility expansion.",
146
+ "parameters": {"bb_length": 20, "bb_std": 2.0},
147
+ "code": '''
148
+ //@version=5
149
+ strategy("Bollinger Breakout", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
150
+
151
+ length = input.int({bb_length}, title="BB Length")
152
+ mult = input.float({bb_std}, title="BB StdDev")
153
+ stop_loss_pct = input.float(3.0, title="Stop Loss %")
154
+
155
+ basis = ta.sma(close, length)
156
+ upper = basis + mult * ta.stdev(close, length)
157
+ lower = basis - mult * ta.stdev(close, length)
158
+
159
+ long_cond = ta.crossover(close, upper)
160
+ exit_cond = ta.crossunder(close, basis)
161
+
162
+ if long_cond
163
+ strategy.entry("Long", strategy.long)
164
+ if exit_cond
165
+ strategy.close("Long")
166
+
167
+ strategy.exit("SL", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100))
168
+
169
+ plot(basis, color=color.orange, title="Basis")
170
+ plot(upper, color=color.blue, title="Upper")
171
+ plot(lower, color=color.blue, title="Lower")
172
+ ''',
173
+ },
174
+
175
+ "supertrend": {
176
+ "id": "supertrend",
177
+ "name": "Supertrend",
178
+ "category": "Trend Following",
179
+ "description": "ATR-based trend following with dynamic support/resistance. Excellent for capturing strong trends.",
180
+ "parameters": {"atr_length": 10, "factor": 3.0},
181
+ "code": '''
182
+ //@version=5
183
+ strategy("Supertrend Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
184
+
185
+ atr_len = input.int({atr_length}, title="ATR Length")
186
+ factor = input.float({factor}, title="Factor")
187
+
188
+ [supertrend, direction] = ta.supertrend(factor, atr_len)
189
+
190
+ long_cond = ta.crossover(close, supertrend)
191
+ short_cond = ta.crossunder(close, supertrend)
192
+
193
+ if long_cond
194
+ strategy.entry("Long", strategy.long)
195
+ if short_cond
196
+ strategy.close("Long")
197
+
198
+ plot(supertrend, color=direction < 0 ? color.green : color.red, title="Supertrend", linewidth=2)
199
+ ''',
200
+ },
201
+
202
+ "ema_ribbon": {
203
+ "id": "ema_ribbon",
204
+ "name": "EMA Ribbon",
205
+ "category": "Trend Following",
206
+ "description": "Multiple EMA fan for trend confirmation. All EMAs aligned = strong trend signal.",
207
+ "parameters": {"ema1": 8, "ema2": 13, "ema3": 21, "ema4": 34, "ema5": 55},
208
+ "code": '''
209
+ //@version=5
210
+ strategy("EMA Ribbon Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
211
+
212
+ e1 = input.int({ema1}, title="EMA 1")
213
+ e2 = input.int({ema2}, title="EMA 2")
214
+ e3 = input.int({ema3}, title="EMA 3")
215
+ e4 = input.int({ema4}, title="EMA 4")
216
+ e5 = input.int({ema5}, title="EMA 5")
217
+ stop_loss_pct = input.float(4.0, title="Stop Loss %")
218
+
219
+ ema1 = ta.ema(close, e1)
220
+ ema2 = ta.ema(close, e2)
221
+ ema3 = ta.ema(close, e3)
222
+ ema4 = ta.ema(close, e4)
223
+ ema5 = ta.ema(close, e5)
224
+
225
+ bullish_ribbon = ema1 > ema2 and ema2 > ema3 and ema3 > ema4 and ema4 > ema5
226
+ bearish_ribbon = ema1 < ema2 and ema2 < ema3 and ema3 < ema4 and ema4 < ema5
227
+
228
+ if bullish_ribbon and not bullish_ribbon[1]
229
+ strategy.entry("Long", strategy.long)
230
+ if bearish_ribbon
231
+ strategy.close("Long")
232
+
233
+ strategy.exit("SL", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100))
234
+
235
+ plot(ema1, color=color.new(#26A69A, 0), linewidth=1)
236
+ plot(ema2, color=color.new(#2196F3, 0), linewidth=1)
237
+ plot(ema3, color=color.new(#FF9800, 0), linewidth=1)
238
+ plot(ema4, color=color.new(#E91E63, 0), linewidth=1)
239
+ plot(ema5, color=color.new(#9C27B0, 0), linewidth=1)
240
+ ''',
241
+ },
242
+
243
+ "stochastic_rsi_combo": {
244
+ "id": "stochastic_rsi_combo",
245
+ "name": "Stochastic + RSI Combo",
246
+ "category": "Oscillator",
247
+ "description": "Dual oscillator confirmation: requires both Stochastic K/D crossover and RSI alignment.",
248
+ "parameters": {"rsi_len": 14, "stoch_k": 14, "stoch_d": 3},
249
+ "code": '''
250
+ //@version=5
251
+ strategy("Stoch + RSI Combo", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
252
+
253
+ rsi_len = input.int({rsi_len}, title="RSI Length")
254
+ stoch_k = input.int({stoch_k}, title="Stoch K")
255
+ stoch_d = input.int({stoch_d}, title="Stoch D Smoothing")
256
+ stop_loss_pct = input.float(4.0, title="Stop Loss %")
257
+
258
+ rsi_val = ta.rsi(close, rsi_len)
259
+ k = ta.stoch(close, high, low, stoch_k)
260
+ d = ta.sma(k, stoch_d)
261
+
262
+ long_cond = ta.crossover(k, d) and k < 30 and rsi_val < 40
263
+ exit_cond = (k > 80 and rsi_val > 70) or ta.crossunder(k, d)
264
+
265
+ if long_cond
266
+ strategy.entry("Long", strategy.long)
267
+ if exit_cond
268
+ strategy.close("Long")
269
+
270
+ strategy.exit("SL", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100))
271
+ ''',
272
+ },
273
+
274
+ "mean_reversion_zscore": {
275
+ "id": "mean_reversion_zscore",
276
+ "name": "Z-Score Mean Reversion",
277
+ "category": "Statistical",
278
+ "description": "Statistical mean reversion using Z-score. Enters when price deviates significantly from mean.",
279
+ "parameters": {"lookback": 50, "entry_z": 2.0, "exit_z": 0.0},
280
+ "code": '''
281
+ //@version=5
282
+ strategy("Z-Score Mean Reversion", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
283
+
284
+ lookback = input.int({lookback}, title="Lookback Period")
285
+ entry_z = input.float({entry_z}, title="Entry Z-Score")
286
+ exit_z = input.float({exit_z}, title="Exit Z-Score")
287
+ stop_loss_pct = input.float(5.0, title="Stop Loss %")
288
+
289
+ mean = ta.sma(close, lookback)
290
+ sd = ta.stdev(close, lookback)
291
+ z_score = (close - mean) / sd
292
+
293
+ long_cond = z_score < -entry_z
294
+ exit_long = z_score > -exit_z
295
+
296
+ if long_cond
297
+ strategy.entry("Long", strategy.long)
298
+ if exit_long
299
+ strategy.close("Long")
300
+
301
+ strategy.exit("SL", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100))
302
+
303
+ hline(0, color=color.gray)
304
+ ''',
305
+ },
306
+
307
+ "atr_trailing_stop": {
308
+ "id": "atr_trailing_stop",
309
+ "name": "ATR Trailing Stop",
310
+ "category": "Risk-Managed",
311
+ "description": "Trend-following with dynamic ATR-based trailing stop. Captures trends with tight risk control.",
312
+ "parameters": {"atr_length": 14, "atr_multiplier": 2.5, "ema_length": 50},
313
+ "code": '''
314
+ //@version=5
315
+ strategy("ATR Trailing Stop", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
316
+
317
+ atr_len = input.int({atr_length}, title="ATR Length")
318
+ atr_mult = input.float({atr_multiplier}, title="ATR Multiplier")
319
+ ema_len = input.int({ema_length}, title="EMA Length")
320
+
321
+ atr_val = ta.atr(atr_len)
322
+ ema_val = ta.ema(close, ema_len)
323
+
324
+ long_cond = close > ema_val and close > close[1]
325
+
326
+ if long_cond and strategy.position_size == 0
327
+ strategy.entry("Long", strategy.long)
328
+
329
+ trail_stop = strategy.position_avg_price > 0 ? close - atr_val * atr_mult : na
330
+ strategy.exit("Trail", "Long", trail_offset=atr_val * atr_mult / syminfo.mintick)
331
+
332
+ plot(ema_val, color=color.blue, title="EMA")
333
+ ''',
334
+ },
335
+
336
+ "multi_timeframe": {
337
+ "id": "multi_timeframe",
338
+ "name": "Multi-Timeframe Confirmation",
339
+ "category": "Advanced",
340
+ "description": "Uses higher timeframe trend confirmation with lower timeframe entry. Professional-grade approach.",
341
+ "parameters": {"htf": "D", "ltf_ema": 20, "htf_ema": 50},
342
+ "code": '''
343
+ //@version=5
344
+ strategy("Multi-TF Confirmation", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
345
+
346
+ htf = input.timeframe("{htf}", title="Higher Timeframe")
347
+ ltf_ema_len = input.int({ltf_ema}, title="LTF EMA Length")
348
+ htf_ema_len = input.int({htf_ema}, title="HTF EMA Length")
349
+ stop_loss_pct = input.float(3.0, title="Stop Loss %")
350
+ take_profit_pct = input.float(6.0, title="Take Profit %")
351
+
352
+ ltf_ema = ta.ema(close, ltf_ema_len)
353
+ htf_close = request.security(syminfo.tickerid, htf, close)
354
+ htf_ema = request.security(syminfo.tickerid, htf, ta.ema(close, htf_ema_len))
355
+
356
+ htf_bullish = htf_close > htf_ema
357
+ ltf_signal = ta.crossover(close, ltf_ema)
358
+
359
+ long_cond = htf_bullish and ltf_signal
360
+ exit_cond = close < ltf_ema and not htf_bullish
361
+
362
+ if long_cond
363
+ strategy.entry("Long", strategy.long)
364
+ if exit_cond
365
+ strategy.close("Long")
366
+
367
+ strategy.exit("SL/TP", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100), limit=strategy.position_avg_price * (1 + take_profit_pct / 100))
368
+
369
+ plot(ltf_ema, color=color.blue, title="LTF EMA")
370
+ bgcolor(htf_bullish ? color.new(color.green, 95) : color.new(color.red, 95))
371
+ ''',
372
+ },
373
+
374
+ "vwap_strategy": {
375
+ "id": "vwap_strategy",
376
+ "name": "VWAP Bounce",
377
+ "category": "Intraday",
378
+ "description": "Institutional VWAP-based strategy. Buys on pullback to VWAP with volume confirmation.",
379
+ "parameters": {"vol_mult": 1.5},
380
+ "code": '''
381
+ //@version=5
382
+ strategy("VWAP Bounce", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
383
+
384
+ vol_mult = input.float({vol_mult}, title="Volume Multiplier")
385
+ stop_loss_pct = input.float(2.0, title="Stop Loss %")
386
+ take_profit_pct = input.float(4.0, title="Take Profit %")
387
+
388
+ vwap_val = ta.vwap
389
+ vol_avg = ta.sma(volume, 20)
390
+ high_vol = volume > vol_avg * vol_mult
391
+
392
+ bounce = close > vwap_val and close[1] <= vwap_val[1] and high_vol
393
+
394
+ if bounce
395
+ strategy.entry("Long", strategy.long)
396
+ if close < vwap_val and strategy.position_size > 0
397
+ strategy.close("Long")
398
+
399
+ strategy.exit("SL/TP", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100), limit=strategy.position_avg_price * (1 + take_profit_pct / 100))
400
+
401
+ plot(vwap_val, color=color.purple, title="VWAP", linewidth=2)
402
+ ''',
403
+ },
404
+
405
+ "ichimoku_cloud": {
406
+ "id": "ichimoku_cloud",
407
+ "name": "Ichimoku Cloud",
408
+ "category": "Multi-Signal",
409
+ "description": "Complete Ichimoku Kinko Hyo system with cloud, conversion, and base line signals.",
410
+ "parameters": {"conv": 9, "base": 26, "span": 52},
411
+ "code": '''
412
+ //@version=5
413
+ strategy("Ichimoku Cloud Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
414
+
415
+ conv_len = input.int({conv}, title="Conversion Length")
416
+ base_len = input.int({base}, title="Base Length")
417
+ span_len = input.int({span}, title="Span B Length")
418
+ stop_loss_pct = input.float(4.0, title="Stop Loss %")
419
+
420
+ donchian(len) => math.avg(ta.lowest(len), ta.highest(len))
421
+
422
+ conv_line = donchian(conv_len)
423
+ base_line = donchian(base_len)
424
+ lead_a = math.avg(conv_line, base_line)
425
+ lead_b = donchian(span_len)
426
+
427
+ above_cloud = close > lead_a and close > lead_b
428
+ tk_cross = ta.crossover(conv_line, base_line)
429
+
430
+ long_cond = tk_cross and above_cloud
431
+ exit_cond = ta.crossunder(conv_line, base_line) or close < lead_b
432
+
433
+ if long_cond
434
+ strategy.entry("Long", strategy.long)
435
+ if exit_cond
436
+ strategy.close("Long")
437
+
438
+ strategy.exit("SL", "Long", stop=strategy.position_avg_price * (1 - stop_loss_pct / 100))
439
+
440
+ plot(conv_line, color=color.blue, title="Conversion")
441
+ plot(base_line, color=color.red, title="Base")
442
+ p1 = plot(lead_a, offset=base_len, color=color.green, title="Lead A")
443
+ p2 = plot(lead_b, offset=base_len, color=color.red, title="Lead B")
444
+ fill(p1, p2, color=lead_a > lead_b ? color.new(color.green, 90) : color.new(color.red, 90))
445
+ ''',
446
+ },
447
+
448
+ # ── Hedge-Focused Strategies ──────────────────────────────────────────
449
+
450
+ "portfolio_hedge": {
451
+ "id": "portfolio_hedge",
452
+ "name": "Dynamic Portfolio Hedge",
453
+ "category": "Hedging",
454
+ "description": "Automatically sizes inverse hedging position based on SMA trend + volatility regime. Increases hedge in high-vol downtrends, reduces in calm uptrends.",
455
+ "parameters": {"trend_sma": 50, "vol_lookback": 20, "max_hedge_pct": 50, "min_hedge_pct": 5},
456
+ "code": '''
457
+ //@version=5
458
+ strategy("Dynamic Portfolio Hedge", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
459
+
460
+ // Inputs
461
+ trend_len = input.int({trend_sma}, title="Trend SMA Length")
462
+ vol_len = input.int({vol_lookback}, title="Volatility Lookback")
463
+ max_hedge = input.float({max_hedge_pct}, title="Max Hedge %", minval=1, maxval=100)
464
+ min_hedge = input.float({min_hedge_pct}, title="Min Hedge %", minval=0, maxval=50)
465
+
466
+ // Core calculations
467
+ trend_sma = ta.sma(close, trend_len)
468
+ ret = math.log(close / close[1])
469
+ hist_vol = ta.stdev(ret, vol_len) * math.sqrt(252) * 100
470
+ norm_vol = math.min(hist_vol / 30.0, 2.0) // Normalized: 30% vol = 1.0
471
+
472
+ // Regime detection
473
+ below_trend = close < trend_sma
474
+ trend_distance = (close - trend_sma) / trend_sma * 100
475
+
476
+ // Dynamic hedge sizing
477
+ hedge_score = 0.0
478
+ hedge_score := below_trend ? math.min(max_hedge, min_hedge + math.abs(trend_distance) * norm_vol * 10) : min_hedge
479
+
480
+ // Signals
481
+ hedge_up = hedge_score > hedge_score[1] * 1.2 and hedge_score > 15
482
+ hedge_down = hedge_score < hedge_score[1] * 0.7 and hedge_score < 10
483
+
484
+ if hedge_up and strategy.position_size <= 0
485
+ strategy.entry("Hedge Short", strategy.short, qty=strategy.equity * hedge_score / 100 / close)
486
+ if hedge_down
487
+ strategy.close("Hedge Short")
488
+
489
+ // Visuals
490
+ plot(trend_sma, color=color.blue, title="Trend SMA", linewidth=2)
491
+ plot(hedge_score, color=color.orange, title="Hedge Score %", display=display.pane)
492
+ bgcolor(below_trend ? color.new(color.red, 95) : color.new(color.green, 95))
493
+ hline(max_hedge, color=color.red, linestyle=hline.style_dashed, title="Max Hedge")
494
+ hline(min_hedge, color=color.green, linestyle=hline.style_dashed, title="Min Hedge")
495
+ ''',
496
+ },
497
+
498
+ "pairs_trading_hedge": {
499
+ "id": "pairs_trading_hedge",
500
+ "name": "Pairs Trading Hedge",
501
+ "category": "Hedging",
502
+ "description": "Statistical arbitrage between correlated pairs. Uses z-score of price ratio for mean reversion entries. Market-neutral hedging approach.",
503
+ "parameters": {"lookback": 60, "entry_z": 2.0, "exit_z": 0.5, "stop_z": 3.5},
504
+ "code": '''
505
+ //@version=5
506
+ strategy("Pairs Trading Hedge", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
507
+
508
+ // Inputs
509
+ lookback = input.int({lookback}, title="Lookback Period")
510
+ entry_z = input.float({entry_z}, title="Entry Z-Score")
511
+ exit_z = input.float({exit_z}, title="Exit Z-Score")
512
+ stop_z = input.float({stop_z}, title="Stop Loss Z-Score")
513
+
514
+ // Spread calculation (self-referencing mean reversion)
515
+ mean_price = ta.sma(close, lookback)
516
+ std_price = ta.stdev(close, lookback)
517
+ z_score = (close - mean_price) / std_price
518
+
519
+ // Entry signals
520
+ long_entry = z_score < -entry_z // Oversold: buy
521
+ short_entry = z_score > entry_z // Overbought: short (hedge)
522
+
523
+ // Exit signals
524
+ long_exit = z_score > -exit_z
525
+ short_exit = z_score < exit_z
526
+
527
+ // Stop loss
528
+ long_stop = z_score < -stop_z
529
+ short_stop = z_score > stop_z
530
+
531
+ // Execution
532
+ if long_entry
533
+ strategy.entry("Long Pair", strategy.long)
534
+ if short_entry
535
+ strategy.entry("Short Hedge", strategy.short)
536
+ if long_exit or long_stop
537
+ strategy.close("Long Pair")
538
+ if short_exit or short_stop
539
+ strategy.close("Short Hedge")
540
+
541
+ // Visualization
542
+ plot(z_score, color=color.blue, title="Z-Score", display=display.pane)
543
+ hline(entry_z, color=color.red, linestyle=hline.style_dashed)
544
+ hline(-entry_z, color=color.green, linestyle=hline.style_dashed)
545
+ hline(0, color=color.gray)
546
+ bgcolor(z_score > entry_z ? color.new(color.red, 90) : z_score < -entry_z ? color.new(color.green, 90) : na)
547
+ ''',
548
+ },
549
+
550
+ "tail_risk_hedge": {
551
+ "id": "tail_risk_hedge",
552
+ "name": "Tail Risk Protection",
553
+ "category": "Hedging",
554
+ "description": "Simulates protective put strategy. Activates hedging when implied volatility spikes and trend breaks down. Protects against black swan events.",
555
+ "parameters": {"vol_threshold": 25, "trend_ma": 200, "hedge_trigger_pct": 5},
556
+ "code": '''
557
+ //@version=5
558
+ strategy("Tail Risk Protection", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
559
+
560
+ // Inputs
561
+ vol_thresh = input.float({vol_threshold}, title="Volatility Threshold %")
562
+ trend_ma_len = input.int({trend_ma}, title="Trend MA Length")
563
+ hedge_trigger = input.float({hedge_trigger_pct}, title="Hedge Trigger Drop %")
564
+
565
+ // Calculations
566
+ ret = math.log(close / close[1])
567
+ hist_vol = ta.stdev(ret, 20) * math.sqrt(252) * 100
568
+ trend_line = ta.sma(close, trend_ma_len)
569
+ recent_high = ta.highest(close, 20)
570
+ drawdown_pct = (recent_high - close) / recent_high * 100
571
+
572
+ // Tail risk detection
573
+ vol_spike = hist_vol > vol_thresh
574
+ trend_break = close < trend_line
575
+ sharp_drop = drawdown_pct > hedge_trigger
576
+ tail_risk = vol_spike and (trend_break or sharp_drop)
577
+
578
+ // Hedge activation
579
+ if tail_risk and strategy.position_size >= 0
580
+ strategy.entry("Tail Hedge", strategy.short)
581
+
582
+ // Release hedge when volatility normalizes + trend recovers
583
+ if hist_vol < vol_thresh * 0.7 and close > trend_line
584
+ strategy.close("Tail Hedge")
585
+
586
+ // Visuals
587
+ plot(trend_line, color=color.blue, title="200 MA", linewidth=2)
588
+ plot(hist_vol, color=vol_spike ? color.red : color.green, title="Hist Vol %", display=display.pane)
589
+ bgcolor(tail_risk ? color.new(color.red, 85) : na)
590
+ plotshape(tail_risk and not tail_risk[1], title="Hedge Activated", style=shape.triangledown, location=location.abovebar, color=color.red, size=size.small)
591
+ ''',
592
+ },
593
+
594
+ "correlation_hedge": {
595
+ "id": "correlation_hedge",
596
+ "name": "Correlation Hedge",
597
+ "category": "Hedging",
598
+ "description": "Trades inverse-correlated asset when primary shows weakness. Uses rolling correlation and momentum divergence for hedge timing.",
599
+ "parameters": {"corr_lookback": 30, "momentum_len": 14, "hedge_threshold": -0.5},
600
+ "code": '''
601
+ //@version=5
602
+ strategy("Correlation Hedge Strategy", overlay=true, default_qty_type=strategy.percent_of_equity, default_qty_value=100, commission_type=strategy.commission.percent, commission_value=0.1)
603
+
604
+ // Inputs
605
+ corr_len = input.int({corr_lookback}, title="Correlation Lookback")
606
+ mom_len = input.int({momentum_len}, title="Momentum Length")
607
+ stop_loss_pct = input.float(5.0, title="Stop Loss %")
608
+ take_profit_pct = input.float(10.0, title="Take Profit %")
609
+
610
+ // Momentum analysis
611
+ momentum = ta.mom(close, mom_len)
612
+ mom_sma = ta.sma(momentum, mom_len)
613
+ momentum_weakening = momentum < 0 and momentum < mom_sma
614
+
615
+ // Volatility regime
616
+ ret = math.log(close / close[1])
617
+ hist_vol = ta.stdev(ret, 20) * math.sqrt(252) * 100
618
+ rising_vol = hist_vol > ta.sma(hist_vol, 50)
619
+
620
+ // Trend analysis
621
+ ma_50 = ta.sma(close, 50)
622
+ ma_200 = ta.sma(close, 200)
623
+ bearish_trend = ma_50 < ma_200
624
+
625
+ // Hedge activation: weakness + rising vol + bearish trend
626
+ hedge_signal = momentum_weakening and rising_vol and bearish_trend
627
+ hedge_exit = momentum > 0 and not rising_vol
628
+
629
+ if hedge_signal and strategy.position_size >= 0
630
+ strategy.entry("Corr Hedge", strategy.short)
631
+ if hedge_exit
632
+ strategy.close("Corr Hedge")
633
+
634
+ strategy.exit("Exit", "Corr Hedge", stop=strategy.position_avg_price * (1 + stop_loss_pct / 100), limit=strategy.position_avg_price * (1 - take_profit_pct / 100))
635
+
636
+ // Visuals
637
+ plot(ma_50, color=color.blue, title="50 MA")
638
+ plot(ma_200, color=color.red, title="200 MA")
639
+ plot(momentum, color=momentum > 0 ? color.green : color.red, title="Momentum", display=display.pane)
640
+ bgcolor(hedge_signal ? color.new(color.orange, 90) : na)
641
+ ''',
642
+ },
643
+ }
644
+
645
+
646
+ # ── LLM Generator ───────────────────────────────────────────────────────
647
+
648
+ PINE_SCRIPT_SYSTEM_PROMPT = """You are an expert TradingView Pine Script v5 developer.
649
+
650
+ Generate ONLY valid Pine Script v5 code. Follow these rules:
651
+ 1. Always start with //@version=5
652
+ 2. Use strategy() for backtestable strategies
653
+ 3. Use input.int(), input.float(), input.bool() for user parameters
654
+ 4. Use ta.* namespace for all built-in indicators
655
+ 5. Use strategy.entry() and strategy.exit() for order management
656
+ 6. Include stop loss and take profit via strategy.exit()
657
+ 7. Add plot() calls for visual indicators on chart
658
+ 8. Use proper variable naming (snake_case)
659
+ 9. Add commission via strategy() declaration
660
+ 10. Make the code clean, well-commented, and production-ready
661
+
662
+ Output ONLY the Pine Script code, nothing else."""
663
+
664
+
665
+ async def generate_from_description(
666
+ description: str,
667
+ parameters: Optional[Dict[str, Any]] = None,
668
+ ) -> Dict[str, Any]:
669
+ """
670
+ Generate Pine Script v5 code from a natural language description.
671
+ Uses Groq LLM for intelligent code generation.
672
+ """
673
+ if not _settings.groq_api_key:
674
+ # Fallback: find closest template match
675
+ return _template_fallback(description)
676
+
677
+ user_prompt = f"Generate a complete TradingView Pine Script v5 strategy for:\n\n{description}"
678
+ if parameters:
679
+ user_prompt += f"\n\nUse these parameters: {parameters}"
680
+
681
+ try:
682
+ async with aiohttp.ClientSession() as session:
683
+ async with session.post(
684
+ "https://api.groq.com/openai/v1/chat/completions",
685
+ headers={
686
+ "Authorization": f"Bearer {_settings.groq_api_key}",
687
+ "Content-Type": "application/json",
688
+ },
689
+ json={
690
+ "model": "llama-3.3-70b-versatile",
691
+ "messages": [
692
+ {"role": "system", "content": PINE_SCRIPT_SYSTEM_PROMPT},
693
+ {"role": "user", "content": user_prompt},
694
+ ],
695
+ "temperature": 0.2,
696
+ "max_tokens": 3000,
697
+ },
698
+ timeout=aiohttp.ClientTimeout(total=30),
699
+ ) as resp:
700
+ if resp.status != 200:
701
+ return _template_fallback(description)
702
+ data = await resp.json()
703
+ code = data["choices"][0]["message"]["content"]
704
+
705
+ # Clean code (remove markdown fences if present)
706
+ if "```" in code:
707
+ parts = code.split("```")
708
+ for part in parts:
709
+ if "//@version=5" in part:
710
+ code = part.replace("pine", "", 1).strip()
711
+ break
712
+
713
+ return {
714
+ "code": code,
715
+ "source": "llm",
716
+ "description": description,
717
+ "valid": "//@version=5" in code,
718
+ }
719
+ except Exception as e:
720
+ logger.warning("LLM generation failed: %s", e)
721
+ return _template_fallback(description)
722
+
723
+
724
+ def generate_from_template(
725
+ template_id: str,
726
+ parameters: Optional[Dict[str, Any]] = None,
727
+ ) -> Dict[str, Any]:
728
+ """Generate Pine Script from a pre-built template."""
729
+ template = STRATEGY_TEMPLATES.get(template_id)
730
+ if not template:
731
+ raise ValueError(f"Template '{template_id}' not found")
732
+
733
+ # Merge default params with overrides
734
+ params = {**template["parameters"]}
735
+ if parameters:
736
+ params.update(parameters)
737
+
738
+ # Format code with parameters
739
+ code = template["code"].strip()
740
+ for key, value in params.items():
741
+ code = code.replace(f"{{{key}}}", str(value))
742
+
743
+ return {
744
+ "code": code,
745
+ "source": "template",
746
+ "template_id": template_id,
747
+ "template_name": template["name"],
748
+ "parameters": params,
749
+ "valid": True,
750
+ }
751
+
752
+
753
+ async def customize_code(
754
+ existing_code: str,
755
+ modification: str,
756
+ ) -> Dict[str, Any]:
757
+ """Modify existing Pine Script via LLM."""
758
+ if not _settings.groq_api_key:
759
+ return {"code": existing_code, "source": "unchanged", "error": "LLM unavailable"}
760
+
761
+ try:
762
+ async with aiohttp.ClientSession() as session:
763
+ async with session.post(
764
+ "https://api.groq.com/openai/v1/chat/completions",
765
+ headers={
766
+ "Authorization": f"Bearer {_settings.groq_api_key}",
767
+ "Content-Type": "application/json",
768
+ },
769
+ json={
770
+ "model": "llama-3.3-70b-versatile",
771
+ "messages": [
772
+ {"role": "system", "content": PINE_SCRIPT_SYSTEM_PROMPT},
773
+ {"role": "user", "content": f"Modify this Pine Script code:\n\n```\n{existing_code}\n```\n\nModification: {modification}\n\nOutput only the complete modified code."},
774
+ ],
775
+ "temperature": 0.2,
776
+ "max_tokens": 3000,
777
+ },
778
+ timeout=aiohttp.ClientTimeout(total=30),
779
+ ) as resp:
780
+ if resp.status != 200:
781
+ return {"code": existing_code, "source": "unchanged", "error": "LLM failed"}
782
+ data = await resp.json()
783
+ code = data["choices"][0]["message"]["content"]
784
+ if "```" in code:
785
+ parts = code.split("```")
786
+ for part in parts:
787
+ if "//@version=5" in part:
788
+ code = part.replace("pine", "", 1).strip()
789
+ break
790
+ return {"code": code, "source": "llm_modified", "valid": "//@version=5" in code}
791
+ except Exception as e:
792
+ logger.warning("LLM customization failed: %s", e)
793
+ return {"code": existing_code, "source": "unchanged", "error": str(e)}
794
+
795
+
796
+ def get_all_templates() -> List[Dict[str, Any]]:
797
+ """Return all available templates (without full code for listing)."""
798
+ return [
799
+ {
800
+ "id": t["id"],
801
+ "name": t["name"],
802
+ "category": t["category"],
803
+ "description": t["description"],
804
+ "parameters": t["parameters"],
805
+ }
806
+ for t in STRATEGY_TEMPLATES.values()
807
+ ]
808
+
809
+
810
+ def _template_fallback(description: str) -> Dict[str, Any]:
811
+ """Keyword-based template matching when LLM is unavailable."""
812
+ desc_lower = description.lower()
813
+ best_match = "sma_crossover" # default
814
+
815
+ keywords = {
816
+ "rsi": "rsi_reversal",
817
+ "macd": "macd_signal",
818
+ "bollinger": "bollinger_breakout",
819
+ "supertrend": "supertrend",
820
+ "ema": "ema_ribbon",
821
+ "stochastic": "stochastic_rsi_combo",
822
+ "z-score": "mean_reversion_zscore",
823
+ "mean reversion": "mean_reversion_zscore",
824
+ "trailing": "atr_trailing_stop",
825
+ "atr": "atr_trailing_stop",
826
+ "multi": "multi_timeframe",
827
+ "timeframe": "multi_timeframe",
828
+ "vwap": "vwap_strategy",
829
+ "ichimoku": "ichimoku_cloud",
830
+ "cloud": "ichimoku_cloud",
831
+ "crossover": "sma_crossover",
832
+ "moving average": "sma_crossover",
833
+ "hedge": "portfolio_hedge",
834
+ "hedging": "portfolio_hedge",
835
+ "portfolio hedge": "portfolio_hedge",
836
+ "pairs": "pairs_trading_hedge",
837
+ "pairs trading": "pairs_trading_hedge",
838
+ "arbitrage": "pairs_trading_hedge",
839
+ "tail risk": "tail_risk_hedge",
840
+ "black swan": "tail_risk_hedge",
841
+ "protection": "tail_risk_hedge",
842
+ "protective": "tail_risk_hedge",
843
+ "correlation": "correlation_hedge",
844
+ "inverse": "correlation_hedge",
845
+ }
846
+
847
+ for keyword, template_id in keywords.items():
848
+ if keyword in desc_lower:
849
+ best_match = template_id
850
+ break
851
+
852
+ result = generate_from_template(best_match)
853
+ result["source"] = "template_fallback"
854
+ result["matched_keyword"] = best_match
855
+ return result
backend/app/services/pinescript/pine_backtester.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Internal Pine Script Backtester.
3
+
4
+ Translates the *intent* of Pine Script strategies into Python
5
+ and executes them against real historical data via yfinance.
6
+ This is NOT a full Pine Script parser — it maps the generated
7
+ template strategies to the existing BacktestEngine.
8
+
9
+ Produces TradingView-style performance metrics:
10
+ - Net Profit, Gross Profit, Gross Loss
11
+ - Max Drawdown, Profit Factor
12
+ - Sharpe Ratio, Sortino Ratio
13
+ - Win Rate, Average Win/Loss
14
+ - Equity Curve, Monthly Returns
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import re
21
+ from typing import Any, Dict, List, Optional, Tuple
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class PineScriptBacktester:
30
+ """
31
+ Simulates Pine Script strategies using Python.
32
+
33
+ For template-generated strategies, maps the strategy logic
34
+ directly to pandas operations for accurate backtesting.
35
+ """
36
+
37
+ async def backtest(
38
+ self,
39
+ code: str,
40
+ ticker: str = "SPY",
41
+ period: str = "3y",
42
+ initial_capital: float = 100_000,
43
+ commission_pct: float = 0.1,
44
+ ) -> Dict[str, Any]:
45
+ """
46
+ Run a backtest on Pine Script code.
47
+ """
48
+ from app.services.data_ingestion.yahoo import yahoo_adapter
49
+
50
+ # Fetch data
51
+ df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
52
+ if df.empty or len(df) < 50:
53
+ raise ValueError(f"Insufficient data for {ticker}")
54
+
55
+ # Detect strategy type from code and run appropriate backtest
56
+ strategy_type = self._detect_strategy_type(code)
57
+ params = self._extract_parameters(code)
58
+
59
+ signals = self._generate_signals(df, strategy_type, params)
60
+ results = self._simulate_trades(df, signals, initial_capital, commission_pct)
61
+
62
+ return {
63
+ "ticker": ticker,
64
+ "period": period,
65
+ "strategy_type": strategy_type,
66
+ "initial_capital": initial_capital,
67
+ **results,
68
+ }
69
+
70
+ def _detect_strategy_type(self, code: str) -> str:
71
+ """Determine strategy type from Pine Script code keywords."""
72
+ code_lower = code.lower()
73
+
74
+ if "ta.supertrend" in code_lower:
75
+ return "supertrend"
76
+ if "ta.macd" in code_lower:
77
+ return "macd"
78
+ if "ta.rsi" in code_lower and "ta.stoch" in code_lower:
79
+ return "stochastic_rsi"
80
+ if "ta.rsi" in code_lower:
81
+ return "rsi"
82
+ if "ta.bb" in code_lower or "bollinger" in code_lower or "ta.stdev" in code_lower:
83
+ return "bollinger"
84
+ if "ta.vwap" in code_lower or "vwap" in code_lower:
85
+ return "vwap"
86
+ if "ichimoku" in code_lower or "donchian" in code_lower:
87
+ return "ichimoku"
88
+ if "z_score" in code_lower or "z-score" in code_lower:
89
+ return "zscore"
90
+ if "ema" in code_lower and code_lower.count("ta.ema") >= 3:
91
+ return "ema_ribbon"
92
+ if "crossover" in code_lower and "sma" in code_lower:
93
+ return "sma_crossover"
94
+ if "ta.atr" in code_lower and "trail" in code_lower:
95
+ return "atr_trailing"
96
+ if "request.security" in code_lower:
97
+ return "multi_timeframe"
98
+
99
+ return "sma_crossover" # default
100
+
101
+ def _extract_parameters(self, code: str) -> Dict[str, Any]:
102
+ """Extract input parameters from Pine Script code."""
103
+ params: Dict[str, Any] = {}
104
+
105
+ # Match input.int(value, ...) and input.float(value, ...)
106
+ int_matches = re.findall(r'(\w+)\s*=\s*input\.int\((\d+)', code)
107
+ float_matches = re.findall(r'(\w+)\s*=\s*input\.float\(([\d.]+)', code)
108
+
109
+ for name, val in int_matches:
110
+ params[name] = int(val)
111
+ for name, val in float_matches:
112
+ params[name] = float(val)
113
+
114
+ return params
115
+
116
+ def _generate_signals(
117
+ self,
118
+ df: pd.DataFrame,
119
+ strategy_type: str,
120
+ params: Dict[str, Any],
121
+ ) -> pd.Series:
122
+ """
123
+ Generate trading signals: +1 = buy, -1 = sell, 0 = hold.
124
+ Maps each strategy type to Python logic.
125
+ """
126
+ close = df["Close"]
127
+ high = df["High"]
128
+ low = df["Low"]
129
+ volume = df["Volume"] if "Volume" in df.columns else pd.Series(0, index=df.index)
130
+ signals = pd.Series(0, index=df.index)
131
+
132
+ if strategy_type == "sma_crossover":
133
+ fast = params.get("fast_length", 20)
134
+ slow = params.get("slow_length", 50)
135
+ sma_fast = close.rolling(fast).mean()
136
+ sma_slow = close.rolling(slow).mean()
137
+ signals[sma_fast > sma_slow] = 1
138
+ signals[sma_fast <= sma_slow] = -1
139
+
140
+ elif strategy_type == "rsi":
141
+ length = params.get("rsi_length", 14)
142
+ oversold = params.get("oversold_level", 30)
143
+ overbought = params.get("overbought_level", 70)
144
+ delta = close.diff()
145
+ gain = delta.where(delta > 0, 0).rolling(length).mean()
146
+ loss = (-delta.where(delta < 0, 0)).rolling(length).mean()
147
+ rs = gain / (loss + 1e-10)
148
+ rsi = 100 - (100 / (1 + rs))
149
+ signals[rsi < oversold] = 1
150
+ signals[rsi > overbought] = -1
151
+
152
+ elif strategy_type == "macd":
153
+ fast = params.get("fast_len", 12)
154
+ slow = params.get("slow_len", 26)
155
+ sig = params.get("sig_len", 9)
156
+ ema_fast = close.ewm(span=fast).mean()
157
+ ema_slow = close.ewm(span=slow).mean()
158
+ macd_line = ema_fast - ema_slow
159
+ signal_line = macd_line.ewm(span=sig).mean()
160
+ hist = macd_line - signal_line
161
+ signals[(macd_line > signal_line) & (hist > 0)] = 1
162
+ signals[macd_line < signal_line] = -1
163
+
164
+ elif strategy_type == "bollinger":
165
+ length = params.get("length", 20)
166
+ mult = params.get("mult", 2.0)
167
+ basis = close.rolling(length).mean()
168
+ std = close.rolling(length).std()
169
+ upper = basis + mult * std
170
+ signals[close > upper] = 1
171
+ signals[close < basis] = -1
172
+
173
+ elif strategy_type == "supertrend":
174
+ atr_len = params.get("atr_len", 10)
175
+ factor = params.get("factor", 3.0)
176
+ tr = pd.concat([
177
+ high - low,
178
+ abs(high - close.shift(1)),
179
+ abs(low - close.shift(1))
180
+ ], axis=1).max(axis=1)
181
+ atr = tr.rolling(atr_len).mean()
182
+ upper_band = (high + low) / 2 + factor * atr
183
+ lower_band = (high + low) / 2 - factor * atr
184
+
185
+ supertrend = pd.Series(0.0, index=df.index)
186
+ direction = pd.Series(1, index=df.index)
187
+ for i in range(1, len(df)):
188
+ if close.iloc[i] > upper_band.iloc[i-1]:
189
+ direction.iloc[i] = 1
190
+ elif close.iloc[i] < lower_band.iloc[i-1]:
191
+ direction.iloc[i] = -1
192
+ else:
193
+ direction.iloc[i] = direction.iloc[i-1]
194
+ signals[direction == 1] = 1
195
+ signals[direction == -1] = -1
196
+
197
+ elif strategy_type == "ema_ribbon":
198
+ emas = [8, 13, 21, 34, 55]
199
+ ema_vals = [close.ewm(span=e).mean() for e in emas]
200
+ bullish = all(ema_vals[i].iloc[-1] > ema_vals[i+1].iloc[-1] for i in range(len(ema_vals)-1))
201
+ for i in range(len(df)):
202
+ if all(ema_vals[j].iloc[i] > ema_vals[j+1].iloc[i] for j in range(len(ema_vals)-1) if i < len(ema_vals[j])):
203
+ signals.iloc[i] = 1
204
+ elif all(ema_vals[j].iloc[i] < ema_vals[j+1].iloc[i] for j in range(len(ema_vals)-1) if i < len(ema_vals[j])):
205
+ signals.iloc[i] = -1
206
+
207
+ elif strategy_type == "zscore":
208
+ lookback = params.get("lookback", 50)
209
+ entry_z = params.get("entry_z", 2.0)
210
+ mean = close.rolling(lookback).mean()
211
+ std = close.rolling(lookback).std()
212
+ z = (close - mean) / (std + 1e-10)
213
+ signals[z < -entry_z] = 1
214
+ signals[z > entry_z] = -1
215
+
216
+ else:
217
+ # Default: SMA crossover
218
+ sma20 = close.rolling(20).mean()
219
+ sma50 = close.rolling(50).mean()
220
+ signals[sma20 > sma50] = 1
221
+ signals[sma20 <= sma50] = -1
222
+
223
+ return signals
224
+
225
+ def _simulate_trades(
226
+ self,
227
+ df: pd.DataFrame,
228
+ signals: pd.Series,
229
+ initial_capital: float,
230
+ commission_pct: float,
231
+ ) -> Dict[str, Any]:
232
+ """
233
+ Simulate trading based on signals and compute all metrics.
234
+ """
235
+ close = df["Close"].values
236
+ sig = signals.values
237
+ n = len(close)
238
+
239
+ # Track positions and equity
240
+ position = 0 # 0 or 1
241
+ entry_price = 0.0
242
+ capital = initial_capital
243
+ equity_curve = [initial_capital]
244
+ trades: List[Dict[str, Any]] = []
245
+ daily_returns: List[float] = [0.0]
246
+
247
+ for i in range(1, n):
248
+ if sig[i] == 1 and position == 0:
249
+ # Buy
250
+ shares = capital / close[i]
251
+ commission = capital * (commission_pct / 100)
252
+ capital -= commission
253
+ entry_price = close[i]
254
+ position = 1
255
+ trades.append({
256
+ "type": "ENTRY",
257
+ "date": str(df.index[i].date()) if hasattr(df.index[i], 'date') else str(df.index[i]),
258
+ "price": round(close[i], 2),
259
+ "shares": round(shares, 4),
260
+ })
261
+
262
+ elif sig[i] == -1 and position == 1:
263
+ # Sell
264
+ ret = (close[i] - entry_price) / entry_price
265
+ commission = capital * (1 + ret) * (commission_pct / 100)
266
+ capital = capital * (1 + ret) - commission
267
+ position = 0
268
+ trades.append({
269
+ "type": "EXIT",
270
+ "date": str(df.index[i].date()) if hasattr(df.index[i], 'date') else str(df.index[i]),
271
+ "price": round(close[i], 2),
272
+ "pnl_pct": round(ret * 100, 2),
273
+ "pnl_abs": round(capital - equity_curve[-1], 2),
274
+ })
275
+
276
+ # Update equity
277
+ if position == 1:
278
+ current_equity = capital * (close[i] / entry_price) if entry_price > 0 else capital
279
+ else:
280
+ current_equity = capital
281
+
282
+ daily_ret = (current_equity - equity_curve[-1]) / equity_curve[-1] if equity_curve[-1] > 0 else 0
283
+ daily_returns.append(daily_ret)
284
+ equity_curve.append(current_equity)
285
+
286
+ # Close open position
287
+ if position == 1:
288
+ ret = (close[-1] - entry_price) / entry_price
289
+ capital = capital * (1 + ret)
290
+
291
+ # Compute metrics
292
+ eq = np.array(equity_curve)
293
+ rets = np.array(daily_returns)
294
+ peak = np.maximum.accumulate(eq)
295
+ drawdown = (eq - peak) / (peak + 1e-10)
296
+
297
+ # Trade stats
298
+ pnl_list = [t.get("pnl_pct", 0) for t in trades if t["type"] == "EXIT"]
299
+ wins = [p for p in pnl_list if p > 0]
300
+ losses = [p for p in pnl_list if p < 0]
301
+
302
+ total_trades = len(pnl_list)
303
+ win_rate = len(wins) / total_trades if total_trades > 0 else 0
304
+ avg_win = np.mean(wins) if wins else 0
305
+ avg_loss = np.mean(losses) if losses else 0
306
+
307
+ net_profit = eq[-1] - initial_capital
308
+ net_profit_pct = (eq[-1] / initial_capital - 1) * 100
309
+ gross_profit = sum(p for p in pnl_list if p > 0)
310
+ gross_loss = abs(sum(p for p in pnl_list if p < 0))
311
+ profit_factor = gross_profit / (gross_loss + 1e-10) if gross_loss > 0 else float("inf")
312
+ max_drawdown = float(np.min(drawdown)) * 100
313
+
314
+ # Annualized metrics
315
+ trading_days = len(rets) - 1
316
+ ann_factor = 252 / max(trading_days, 1)
317
+ ann_return = ((eq[-1] / initial_capital) ** ann_factor - 1) * 100 if trading_days > 0 else 0
318
+
319
+ daily_std = np.std(rets[1:]) if len(rets) > 1 else 0
320
+ sharpe = (np.mean(rets[1:]) / (daily_std + 1e-10)) * np.sqrt(252) if daily_std > 0 else 0
321
+
322
+ downside = rets[rets < 0]
323
+ downside_std = np.std(downside) if len(downside) > 0 else 0
324
+ sortino = (np.mean(rets[1:]) / (downside_std + 1e-10)) * np.sqrt(252) if downside_std > 0 else 0
325
+
326
+ # Monthly returns
327
+ dates = df.index
328
+ monthly_returns = {}
329
+ month_start_eq = equity_curve[0]
330
+ for i in range(1, len(equity_curve)):
331
+ if i < len(dates):
332
+ month_key = dates[i].strftime("%Y-%m") if hasattr(dates[i], 'strftime') else str(dates[i])[:7]
333
+ if i > 0 and (i == len(equity_curve) - 1 or
334
+ (i < len(dates) and hasattr(dates[i], 'month') and
335
+ (dates[i].month != dates[i-1].month if i > 0 and hasattr(dates[i-1], 'month') else False))):
336
+ prev_key = dates[i-1].strftime("%Y-%m") if hasattr(dates[i-1], 'strftime') else str(dates[i-1])[:7]
337
+ monthly_returns[prev_key] = round((equity_curve[i] / month_start_eq - 1) * 100, 2) if month_start_eq > 0 else 0
338
+ month_start_eq = equity_curve[i]
339
+
340
+ # Downsample equity curve for response size
341
+ eq_len = len(equity_curve)
342
+ step = max(1, eq_len // 250)
343
+ sampled_equity = [
344
+ {"index": i, "equity": round(equity_curve[i], 2)}
345
+ for i in range(0, eq_len, step)
346
+ ]
347
+
348
+ return {
349
+ "net_profit": round(net_profit, 2),
350
+ "net_profit_pct": round(net_profit_pct, 2),
351
+ "gross_profit_pct": round(gross_profit, 2),
352
+ "gross_loss_pct": round(gross_loss, 2),
353
+ "profit_factor": round(profit_factor, 4),
354
+ "max_drawdown_pct": round(max_drawdown, 2),
355
+ "sharpe_ratio": round(sharpe, 4),
356
+ "sortino_ratio": round(sortino, 4),
357
+ "annualized_return_pct": round(ann_return, 2),
358
+ "total_trades": total_trades,
359
+ "win_rate": round(win_rate, 4),
360
+ "avg_win_pct": round(avg_win, 2),
361
+ "avg_loss_pct": round(avg_loss, 2),
362
+ "max_consecutive_wins": _max_consecutive(pnl_list, positive=True),
363
+ "max_consecutive_losses": _max_consecutive(pnl_list, positive=False),
364
+ "equity_curve": sampled_equity,
365
+ "trades": trades[-50:], # last 50 trades
366
+ "monthly_returns": monthly_returns,
367
+ "final_equity": round(eq[-1], 2),
368
+ "trading_days": trading_days,
369
+ }
370
+
371
+
372
+ def _max_consecutive(values: list, positive: bool = True) -> int:
373
+ """Count max consecutive wins or losses."""
374
+ max_count = 0
375
+ current = 0
376
+ for v in values:
377
+ if (positive and v > 0) or (not positive and v < 0):
378
+ current += 1
379
+ max_count = max(max_count, current)
380
+ else:
381
+ current = 0
382
+ return max_count
383
+
384
+
385
+ # Module singleton
386
+ pine_backtester = PineScriptBacktester()
backend/app/services/pinescript/validator.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pine Script v5 Syntax Validator.
3
+
4
+ Validates generated Pine Script code for:
5
+ - Version declaration
6
+ - Required structure (strategy or indicator)
7
+ - Bracket/parenthesis matching
8
+ - Known function signatures
9
+ - Common mistakes and deprecated syntax
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ from dataclasses import dataclass
17
+ from typing import Any, Dict, List
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class ValidationResult:
24
+ valid: bool
25
+ errors: List[Dict[str, Any]]
26
+ warnings: List[Dict[str, Any]]
27
+ stats: Dict[str, int]
28
+
29
+
30
+ # Pine Script v5 built-in functions / namespaces
31
+ VALID_NAMESPACES = {
32
+ "ta", "math", "strategy", "input", "color", "plot", "hline",
33
+ "plotshape", "plotchar", "fill", "bgcolor", "barcolor",
34
+ "request", "str", "array", "matrix", "table", "line", "box",
35
+ "label", "syminfo", "time", "timeframe", "chart",
36
+ }
37
+
38
+ VALID_TA_FUNCTIONS = {
39
+ "sma", "ema", "wma", "rma", "vwma", "swma", "alma",
40
+ "rsi", "macd", "stoch", "cci", "atr", "tr",
41
+ "highest", "lowest", "highestbars", "lowestbars",
42
+ "crossover", "crossunder", "cross",
43
+ "change", "mom", "roc", "percentrank",
44
+ "supertrend", "pivot_point_levels",
45
+ "bb", "kc", "vwap",
46
+ "stdev", "variance", "correlation",
47
+ "barssince", "valuewhen",
48
+ "cum", "rising", "falling",
49
+ "dmi", "mfi", "obv",
50
+ }
51
+
52
+ DEPRECATED_V4 = {
53
+ "study": "indicator",
54
+ "security": "request.security",
55
+ "input": "input.int/input.float/input.bool/input.string",
56
+ "iff": "condition ? value1 : value2",
57
+ }
58
+
59
+
60
+ def validate_pine_script(code: str) -> Dict[str, Any]:
61
+ """
62
+ Validate Pine Script v5 code and return errors/warnings.
63
+ """
64
+ errors: List[Dict[str, Any]] = []
65
+ warnings: List[Dict[str, Any]] = []
66
+ lines = code.strip().split("\n")
67
+ stats = {
68
+ "total_lines": len(lines),
69
+ "code_lines": 0,
70
+ "comment_lines": 0,
71
+ }
72
+
73
+ if not code.strip():
74
+ errors.append({"line": 0, "message": "Empty code"})
75
+ return ValidationResult(
76
+ valid=False, errors=errors, warnings=warnings, stats=stats
77
+ ).__dict__
78
+
79
+ # 1. Version declaration check
80
+ has_version = False
81
+ for i, line in enumerate(lines):
82
+ stripped = line.strip()
83
+ if stripped.startswith("//"):
84
+ stats["comment_lines"] += 1
85
+ if stripped.startswith("//@version=5"):
86
+ has_version = True
87
+ elif stripped:
88
+ stats["code_lines"] += 1
89
+
90
+ if not has_version:
91
+ errors.append({
92
+ "line": 1,
93
+ "message": "Missing Pine Script v5 version declaration: //@version=5",
94
+ "severity": "error",
95
+ })
96
+
97
+ # 2. Strategy or indicator declaration
98
+ has_strategy = bool(re.search(r'\bstrategy\s*\(', code))
99
+ has_indicator = bool(re.search(r'\bindicator\s*\(', code))
100
+ if not has_strategy and not has_indicator:
101
+ errors.append({
102
+ "line": 0,
103
+ "message": "Missing strategy() or indicator() declaration",
104
+ "severity": "error",
105
+ })
106
+
107
+ # 3. Bracket matching
108
+ open_parens = 0
109
+ open_brackets = 0
110
+ for i, line in enumerate(lines, 1):
111
+ stripped = line.strip()
112
+ if stripped.startswith("//"):
113
+ continue
114
+ open_parens += stripped.count("(") - stripped.count(")")
115
+ open_brackets += stripped.count("[") - stripped.count("]")
116
+
117
+ if open_parens != 0:
118
+ errors.append({
119
+ "line": 0,
120
+ "message": f"Unmatched parentheses: {'extra (' if open_parens > 0 else 'extra )'}",
121
+ "severity": "error",
122
+ })
123
+ if open_brackets != 0:
124
+ errors.append({
125
+ "line": 0,
126
+ "message": f"Unmatched brackets: {'extra [' if open_brackets > 0 else 'extra ]'}",
127
+ "severity": "error",
128
+ })
129
+
130
+ # 4. Deprecated v4 syntax
131
+ for deprecated, replacement in DEPRECATED_V4.items():
132
+ pattern = rf'\b{deprecated}\s*\('
133
+ match = re.search(pattern, code)
134
+ if match:
135
+ # Find line number
136
+ pos = match.start()
137
+ line_num = code[:pos].count("\n") + 1
138
+ # study() is a special case — it was renamed to indicator()
139
+ if deprecated == "study":
140
+ warnings.append({
141
+ "line": line_num,
142
+ "message": f"Deprecated: '{deprecated}()' was renamed to '{replacement}()' in v5",
143
+ "severity": "warning",
144
+ })
145
+ elif deprecated == "security":
146
+ warnings.append({
147
+ "line": line_num,
148
+ "message": f"Deprecated: '{deprecated}()' should be '{replacement}()' in v5",
149
+ "severity": "warning",
150
+ })
151
+
152
+ # 5. Strategy without entry
153
+ if has_strategy:
154
+ if "strategy.entry" not in code:
155
+ warnings.append({
156
+ "line": 0,
157
+ "message": "Strategy has no strategy.entry() calls — no trades will be executed",
158
+ "severity": "warning",
159
+ })
160
+
161
+ # 6. Check for common mistakes
162
+ if "var " in code:
163
+ # var is valid but sometimes misused
164
+ pass
165
+
166
+ # Missing quotes around strings
167
+ name_match = re.search(r'strategy\(\s*([^"\'])', code)
168
+ if name_match:
169
+ pos = name_match.start()
170
+ line_num = code[:pos].count("\n") + 1
171
+ warnings.append({
172
+ "line": line_num,
173
+ "message": "Strategy name should be a quoted string",
174
+ "severity": "warning",
175
+ })
176
+
177
+ is_valid = len(errors) == 0
178
+
179
+ return {
180
+ "valid": is_valid,
181
+ "errors": [{"line": e["line"], "message": e["message"], "severity": e.get("severity", "error")} for e in errors],
182
+ "warnings": [{"line": w["line"], "message": w["message"], "severity": w.get("severity", "warning")} for w in warnings],
183
+ "stats": stats,
184
+ }
backend/requirements.txt CHANGED
@@ -32,6 +32,7 @@ statsmodels==0.14.4
32
  # ML Models
33
  xgboost==2.1.3
34
  hmmlearn==0.3.3
 
35
 
36
  # HTTP Client
37
  httpx==0.28.1
 
32
  # ML Models
33
  xgboost==2.1.3
34
  hmmlearn==0.3.3
35
+ lightgbm>=4.0
36
 
37
  # HTTP Client
38
  httpx==0.28.1
frontend/src/App.tsx CHANGED
@@ -20,6 +20,8 @@ import BiasDetector from './pages/BiasDetector';
20
  import CrisisReplay from './pages/CrisisReplay';
21
  import PortfolioDNA from './pages/PortfolioDNA';
22
  import Copilot from './pages/Copilot';
 
 
23
  import './index.css';
24
 
25
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -97,6 +99,12 @@ export default function App() {
97
  <Route path="/copilot" element={
98
  <ProtectedRoute><AppLayout><Copilot /></AppLayout></ProtectedRoute>
99
  } />
 
 
 
 
 
 
100
  </Routes>
101
  </BrowserRouter>
102
  );
 
20
  import CrisisReplay from './pages/CrisisReplay';
21
  import PortfolioDNA from './pages/PortfolioDNA';
22
  import Copilot from './pages/Copilot';
23
+ import PatternIntelligence from './pages/PatternIntelligence';
24
+ import PineScriptLab from './pages/PineScriptLab';
25
  import './index.css';
26
 
27
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
 
99
  <Route path="/copilot" element={
100
  <ProtectedRoute><AppLayout><Copilot /></AppLayout></ProtectedRoute>
101
  } />
102
+ <Route path="/pattern-intelligence" element={
103
+ <ProtectedRoute><AppLayout><PatternIntelligence /></AppLayout></ProtectedRoute>
104
+ } />
105
+ <Route path="/pinescript-lab" element={
106
+ <ProtectedRoute><AppLayout><PineScriptLab /></AppLayout></ProtectedRoute>
107
+ } />
108
  </Routes>
109
  </BrowserRouter>
110
  );
frontend/src/api/client.ts CHANGED
@@ -189,4 +189,31 @@ export const copilotAPI = {
189
  api.put(`/copilot/sessions/${sessionId}`, { title }),
190
  };
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  export default api;
 
189
  api.put(`/copilot/sessions/${sessionId}`, { title }),
190
  };
191
 
192
+ // ── Pattern Intelligence ─────────────────────────────────────────────────
193
+ export const patternsAPI = {
194
+ analyze: (data: { ticker: string; period?: string; horizon?: number }) =>
195
+ api.post('/patterns/analyze', data),
196
+ multiAnalyze: (data: { tickers: string[]; period?: string; horizon?: number }) =>
197
+ api.post('/patterns/multi-analyze', data),
198
+ catalog: () => api.get('/patterns/catalog'),
199
+ backtestAccuracy: (data: { ticker: string; period?: string; horizon?: number }) =>
200
+ api.post('/patterns/backtest-accuracy', data),
201
+ clearCache: () => api.post('/patterns/clear-cache'),
202
+ };
203
+
204
+ // ── Pine Script Lab ──────────────────────────────────────────────────────
205
+ export const pinescriptAPI = {
206
+ generate: (data: { description: string; parameters?: any }) =>
207
+ api.post('/pinescript/generate', data),
208
+ templates: () => api.get('/pinescript/templates'),
209
+ generateFromTemplate: (data: { template_id: string; parameters?: any }) =>
210
+ api.post('/pinescript/templates/generate', data),
211
+ validate: (data: { code: string }) =>
212
+ api.post('/pinescript/validate', data),
213
+ backtest: (data: { code: string; ticker?: string; period?: string; initial_capital?: number; commission_pct?: number }) =>
214
+ api.post('/pinescript/backtest', data),
215
+ customize: (data: { code: string; modification: string }) =>
216
+ api.post('/pinescript/customize', data),
217
+ };
218
+
219
  export default api;
frontend/src/components/Sidebar.tsx CHANGED
@@ -88,6 +88,13 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
88
  { to: '/copilot', label: 'HedgeAI', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" clipRule="evenodd" /></svg> },
89
  ]
90
  },
 
 
 
 
 
 
 
91
  ];
92
 
93
  // Primary nav items for mobile bottom bar (5 key items)
 
88
  { to: '/copilot', label: 'HedgeAI', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" clipRule="evenodd" /></svg> },
89
  ]
90
  },
91
+ {
92
+ section: 'AI Engine',
93
+ items: [
94
+ { to: '/pattern-intelligence', label: 'Patterns', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg> },
95
+ { to: '/pinescript-lab', label: 'Pine Script', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg> },
96
+ ]
97
+ },
98
  ];
99
 
100
  // Primary nav items for mobile bottom bar (5 key items)
frontend/src/pages/HoldingsTracker.tsx CHANGED
@@ -1,6 +1,7 @@
1
- import { useState, useEffect } from 'react';
2
  import { holdingsAPI, researchAPI } from '../api/client';
3
  import TickerSearch from '../components/TickerSearch';
 
4
  import {
5
  PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid,
6
  Tooltip, ResponsiveContainer
@@ -128,9 +129,18 @@ export default function HoldingsTracker() {
128
  if (tab === 'insights' && !aiInsight && !aiLoading && holdings.length > 0) loadAiInsight();
129
  }, [tab]);
130
 
 
131
  const fmt = (n: number) => n?.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) ?? '—';
132
  const fmtPct = (n: number) => `${n >= 0 ? '+' : ''}${n?.toFixed(2)}%`;
133
 
 
 
 
 
 
 
 
 
134
  if (loading) {
135
  return <div className="page animate-fade-in"><div className="loading-overlay"><div className="spinner" /><span>Loading portfolio...</span></div></div>;
136
  }
@@ -170,16 +180,30 @@ export default function HoldingsTracker() {
170
 
171
  {/* Summary Metrics */}
172
  {summary && (
173
- <div className="grid-4" style={{ marginBottom: '1.5rem' }}>
174
- <MetricCard icon={Briefcase} label="Portfolio Value" value={`$${fmt(summary.total_value)}`} />
175
- <MetricCard icon={DollarSign} label="Cost Basis" value={`$${fmt(summary.total_cost)}`} color="var(--text-secondary)" />
176
- <MetricCard icon={TrendingDown} label="Total P&L" value={`$${fmt(Math.abs(summary.total_pnl))}`}
177
- color={summary.total_pnl >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'}
178
- sub={fmtPct(summary.total_pnl_pct)} />
179
- <MetricCard icon={Percent} label="Return" value={fmtPct(summary.total_pnl_pct)}
180
- color={summary.total_pnl_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'}
181
- sub={`${holdings.length} position${holdings.length !== 1 ? 's' : ''}`} />
182
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  )}
184
 
185
  {/* Add Holding Form */}
@@ -193,7 +217,7 @@ export default function HoldingsTracker() {
193
  <div className="form-group"><label>Average Price</label><input className="input" type="number" step="any" placeholder="150.00" value={form.avg_price} onChange={e => setForm({ ...form, avg_price: e.target.value })} required /></div>
194
  <div className="form-group"><label>Position Type</label><select value={form.position_type} onChange={e => setForm({ ...form, position_type: e.target.value })}><option value="long">Long</option><option value="short">Short</option></select></div>
195
  <div className="form-group"><label>Asset Class</label><select value={form.asset_class} onChange={e => setForm({ ...form, asset_class: e.target.value })}><option value="equity">Equity</option><option value="etf">ETF</option><option value="crypto">Crypto</option><option value="forex">Forex</option><option value="commodity">Commodity</option><option value="option">Option</option><option value="fixed_income">Fixed Income</option></select></div>
196
- <div className="form-group"><label>Currency</label><select value={form.currency} onChange={e => setForm({ ...form, currency: e.target.value })}><option value="USD">USD</option><option value="INR">INR</option><option value="EUR">EUR</option><option value="GBP">GBP</option><option value="JPY">JPY</option></select></div>
197
  <div className="form-group" style={{ display: 'flex', alignItems: 'flex-end' }}><button className="btn btn-primary" type="submit" style={{ width: '100%' }}>Add Position</button></div>
198
  </form>
199
  </div>
@@ -220,6 +244,7 @@ export default function HoldingsTracker() {
220
  <>
221
  {/* Positions Table */}
222
  <div className="card" style={{ marginBottom: '1.5rem' }}>
 
223
  <div className="card-header">
224
  <h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><BarChart3 size={15}/> Positions ({holdings.length})</h3>
225
  </div>
@@ -240,27 +265,39 @@ export default function HoldingsTracker() {
240
  </tr>
241
  </thead>
242
  <tbody>
243
- {holdings.map(h => (
244
- <tr key={h.id}>
245
- <td style={{ fontWeight: 600, fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.ticker}</td>
246
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.quantity.toLocaleString()}</td>
247
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>${fmt(h.avg_price)}</td>
248
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>${fmt(h.current_price)}</td>
249
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 500 }}>${fmt(h.market_value)}</td>
250
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 600, color: h.pnl >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>
251
- {h.pnl >= 0 ? '+' : '-'}${fmt(Math.abs(h.pnl))}
252
- </td>
253
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 600, color: h.pnl_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>
254
- {fmtPct(h.pnl_pct)}
255
- </td>
256
- <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.weight.toFixed(1)}%</td>
257
- <td><span className={`badge ${h.position_type === 'long' ? 'badge-emerald' : 'badge-rose'}`} style={{fontSize:'0.65rem'}}>{h.position_type.toUpperCase()}</span></td>
258
- <td>
259
- <button onClick={() => handleDelete(h.id)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-muted)',padding:'0.25rem'}} title="Remove position">
260
- <Trash2 size={13}/>
261
- </button>
262
- </td>
263
- </tr>
 
 
 
 
 
 
 
 
 
 
 
 
264
  ))}
265
  </tbody>
266
  </table>
@@ -278,7 +315,7 @@ export default function HoldingsTracker() {
278
  <Pie data={pieData} cx="50%" cy="50%" innerRadius={55} outerRadius={95} dataKey="value" label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} labelLine={{ stroke: '#8892a4', strokeWidth: 1 }}>
279
  {pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
280
  </Pie>
281
- <Tooltip formatter={(v: any) => `$${fmt(Number(v))}`} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem', boxShadow:'var(--shadow-md)' }} />
282
  </PieChart>
283
  </ResponsiveContainer>
284
  </div>
@@ -293,7 +330,7 @@ export default function HoldingsTracker() {
293
  <Pie data={assetClassData} cx="50%" cy="50%" innerRadius={55} outerRadius={95} dataKey="value" label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} labelLine={{ stroke: '#8892a4', strokeWidth: 1 }}>
294
  {assetClassData.map((_, i) => <Cell key={i} fill={COLORS[(i + 3) % COLORS.length]} />)}
295
  </Pie>
296
- <Tooltip formatter={(v: any) => `$${fmt(Number(v))}`} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem' }} />
297
  </PieChart>
298
  </ResponsiveContainer>
299
  </div>
@@ -306,9 +343,9 @@ export default function HoldingsTracker() {
306
  <ResponsiveContainer>
307
  <BarChart data={holdings.map(h => ({ ticker: h.ticker, pnl: h.pnl, pnl_pct: h.pnl_pct }))} layout="vertical">
308
  <CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
309
- <XAxis type="number" tick={{ fontSize: 10, fill: 'var(--chart-axis)' }} tickFormatter={v => `$${Number(v).toLocaleString()}`} />
310
  <YAxis dataKey="ticker" type="category" tick={{ fontSize: 11, fill: 'var(--chart-axis)', fontWeight: 500 }} width={60} />
311
- <Tooltip formatter={(v: any) => `$${fmt(Number(v))}`} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem' }} />
312
  <Bar dataKey="pnl" radius={[0, 4, 4, 0]}>
313
  {holdings.map((h, i) => <Cell key={i} fill={h.pnl >= 0 ? '#0a8f5c' : '#c23030'} />)}
314
  </Bar>
@@ -379,9 +416,9 @@ export default function HoldingsTracker() {
379
  </div>
380
  <p style={{ fontSize:'0.82rem', color:'var(--text-secondary)', marginBottom:'1.25rem' }}>{stressResult.scenario.description}</p>
381
  <div className="grid-4" style={{ marginBottom:'1.25rem' }}>
382
- <MetricCard icon={DollarSign} label="Before" value={`$${fmt(stressResult.summary.total_before)}`} />
383
- <MetricCard icon={DollarSign} label="After" value={`$${fmt(stressResult.summary.total_after)}`} />
384
- <MetricCard icon={TrendingDown} label="Impact" value={`$${fmt(Math.abs(stressResult.summary.total_impact))}`} color="var(--red-negative)" />
385
  <MetricCard icon={Percent} label="Impact %" value={fmtPct(stressResult.summary.total_impact_pct)} color={stressResult.summary.total_impact_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'} />
386
  </div>
387
  <div className="table-container">
@@ -391,10 +428,10 @@ export default function HoldingsTracker() {
391
  {stressResult.impacts.map((imp: any) => (
392
  <tr key={imp.ticker}>
393
  <td style={{ fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{imp.ticker}</td>
394
- <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>${fmt(imp.current_value)}</td>
395
  <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem', color: imp.shock_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>{fmtPct(imp.shock_pct)}</td>
396
- <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem', color: imp.impact_value >= 0 ? 'var(--green-positive)' : 'var(--red-negative)', fontWeight:600 }}>{imp.impact_value >= 0 ? '+' : '-'}${fmt(Math.abs(imp.impact_value))}</td>
397
- <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>${fmt(imp.new_value)}</td>
398
  <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{imp.weight.toFixed(1)}%</td>
399
  </tr>
400
  ))}
@@ -419,7 +456,7 @@ export default function HoldingsTracker() {
419
  <>
420
  {hedgeData.risk_summary && (
421
  <div className="grid-4" style={{ marginBottom: '1.5rem' }}>
422
- <MetricCard icon={DollarSign} label="Net Exposure" value={`$${fmt(hedgeData.risk_summary.net_exposure)}`} />
423
  <MetricCard icon={Shield} label="Hedge Ratio" value={`${hedgeData.risk_summary.hedge_ratio}%`} color="var(--accent)" />
424
  <MetricCard icon={BarChart3} label="Positions" value={hedgeData.risk_summary.position_count} />
425
  <MetricCard icon={Target} label="Diversification" value={`${hedgeData.risk_summary.diversification_score}/100`} color={hedgeData.risk_summary.diversification_score > 60 ? 'var(--green-positive)' : 'var(--amber-neutral)'} />
@@ -480,7 +517,7 @@ export default function HoldingsTracker() {
480
  <h3 style={{fontFamily:'var(--font-mono)',fontSize:'0.95rem'}}>{s.ticker}</h3>
481
  <div className="flex-gap">
482
  <span className="badge badge-primary">Vol: {s.volatility}%</span>
483
- <span style={{ fontSize:'0.75rem', color:'var(--text-muted)' }}>${s.current_price} | {s.quantity} shares | ${s.market_value?.toLocaleString()}</span>
484
  </div>
485
  </div>
486
  {s.strategies?.map((st: any) => (
@@ -491,8 +528,8 @@ export default function HoldingsTracker() {
491
  <span style={{ fontSize:'0.68rem', fontWeight:700, textTransform:'uppercase', color:'var(--red-negative)', letterSpacing:'0.06em' }}>Protective Puts</span>
492
  {st.protective_puts?.map((p: any, i: number) => (
493
  <div key={i} style={{ marginTop:'0.5rem', fontSize:'0.78rem', display:'flex', justifyContent:'space-between' }}>
494
- <span>{p.label} (${p.strike})</span>
495
- <span style={{ fontWeight:600 }}>${p.premium} ({p.cost_pct}%)</span>
496
  </div>
497
  ))}
498
  </div>
@@ -500,8 +537,8 @@ export default function HoldingsTracker() {
500
  <span style={{ fontSize:'0.68rem', fontWeight:700, textTransform:'uppercase', color:'var(--green-positive)', letterSpacing:'0.06em' }}>Covered Calls</span>
501
  {st.covered_calls?.map((c: any, i: number) => (
502
  <div key={i} style={{ marginTop:'0.5rem', fontSize:'0.78rem', display:'flex', justifyContent:'space-between' }}>
503
- <span>{c.label} (${c.strike})</span>
504
- <span style={{ fontWeight:600, color:'var(--green-positive)' }}>+${c.premium} ({c.income_pct}%)</span>
505
  </div>
506
  ))}
507
  </div>
@@ -510,7 +547,7 @@ export default function HoldingsTracker() {
510
  <div style={{ marginTop:'0.5rem', fontSize:'0.78rem' }}>
511
  <div>{st.collar?.description}</div>
512
  <div style={{ marginTop:'0.375rem', fontWeight:600 }}>
513
- Net Cost: <span style={{ color: st.collar?.net_cost > 0 ? 'var(--red-negative)' : 'var(--green-positive)' }}>${st.collar?.net_cost} ({st.collar?.net_cost_pct}%)</span>
514
  </div>
515
  </div>
516
  </div>
@@ -537,7 +574,7 @@ export default function HoldingsTracker() {
537
  <MetricCard icon={RefreshCw} label="Needs Rebalance" value={rebalanceData.summary?.needs_rebalance ? 'Yes' : 'No'} color={rebalanceData.summary?.needs_rebalance ? 'var(--red-negative)' : 'var(--green-positive)'} />
538
  <MetricCard icon={Activity} label="Total Drift" value={`${rebalanceData.summary?.total_drift?.toFixed(1)}%`} color="var(--amber-neutral)" />
539
  <MetricCard icon={BarChart3} label="Trades Needed" value={rebalanceData.summary?.trade_count} />
540
- <MetricCard icon={DollarSign} label="Est. Cost" value={`$${rebalanceData.summary?.estimated_cost}`} />
541
  </div>
542
  <div className="card" style={{ marginBottom:'1.5rem' }}>
543
  <div className="card-header"><h3>Current vs Target Allocation</h3></div>
@@ -551,9 +588,9 @@ export default function HoldingsTracker() {
551
  <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{a.current_pct}%</td>
552
  <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{a.target_pct}%</td>
553
  <td style={{ color: a.drift > 0 ? 'var(--red-negative)' : a.drift < 0 ? 'var(--green-positive)' : 'var(--text-muted)', fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{a.drift > 0 ? '+' : ''}{a.drift}%</td>
554
- <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>${a.current_value?.toLocaleString()}</td>
555
- <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>${a.target_value?.toLocaleString()}</td>
556
- <td style={{ color: a.delta_value > 0 ? 'var(--green-positive)' : 'var(--red-negative)', fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{a.delta_value > 0 ? '+' : ''}${a.delta_value?.toLocaleString()}</td>
557
  </tr>
558
  ))}
559
  </tbody>
@@ -571,7 +608,7 @@ export default function HoldingsTracker() {
571
  <div style={{ fontWeight:600, textTransform:'capitalize', fontSize:'0.82rem' }}>{t.asset_class}</div>
572
  <div style={{ fontSize:'0.72rem', color:'var(--text-muted)' }}>{t.reason}</div>
573
  </div>
574
- <div style={{ fontWeight:700, fontSize:'0.9rem', fontFamily:'var(--font-mono)' }}>${t.amount?.toLocaleString()}</div>
575
  </div>
576
  ))}
577
  </div>
 
1
+ import React, { useState, useEffect } from 'react';
2
  import { holdingsAPI, researchAPI } from '../api/client';
3
  import TickerSearch from '../components/TickerSearch';
4
+ import { formatCurrency, getCurrencySymbol, groupByMarket, getMarketSubtotals, CURRENCY_OPTIONS } from '../utils/currencyUtils';
5
  import {
6
  PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid,
7
  Tooltip, ResponsiveContainer
 
129
  if (tab === 'insights' && !aiInsight && !aiLoading && holdings.length > 0) loadAiInsight();
130
  }, [tab]);
131
 
132
+ const fmtC = (n: number, cur: string = 'USD') => formatCurrency(n, cur);
133
  const fmt = (n: number) => n?.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) ?? '—';
134
  const fmtPct = (n: number) => `${n >= 0 ? '+' : ''}${n?.toFixed(2)}%`;
135
 
136
+ // Group holdings by market
137
+ const marketGroups = groupByMarket(holdings);
138
+ const marketSubtotals = getMarketSubtotals(holdings);
139
+ // Detect primary currency from largest market group
140
+ const primaryCurrency = marketSubtotals.length > 0
141
+ ? marketSubtotals.sort((a, b) => b.totalValue - a.totalValue)[0].currency
142
+ : 'USD';
143
+
144
  if (loading) {
145
  return <div className="page animate-fade-in"><div className="loading-overlay"><div className="spinner" /><span>Loading portfolio...</span></div></div>;
146
  }
 
180
 
181
  {/* Summary Metrics */}
182
  {summary && (
183
+ <>
184
+ {/* Per-Market Subtotals */}
185
+ {marketSubtotals.length > 1 && (
186
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
187
+ {marketSubtotals.map(ms => (
188
+ <div key={ms.market} style={{ padding: '0.4rem 0.75rem', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border-color)', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
189
+ <strong>{ms.market}</strong>
190
+ <span style={{ color: 'var(--text-muted)' }}>{ms.count} pos</span>
191
+ <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{fmtC(ms.totalValue, ms.currency)}</span>
192
+ </div>
193
+ ))}
194
+ </div>
195
+ )}
196
+ <div className="grid-4" style={{ marginBottom: '1.5rem' }}>
197
+ <MetricCard icon={Briefcase} label="Portfolio Value" value={fmtC(summary.total_value, primaryCurrency)} />
198
+ <MetricCard icon={DollarSign} label="Cost Basis" value={fmtC(summary.total_cost, primaryCurrency)} color="var(--text-secondary)" />
199
+ <MetricCard icon={TrendingDown} label="Total P&L" value={fmtC(Math.abs(summary.total_pnl), primaryCurrency)}
200
+ color={summary.total_pnl >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'}
201
+ sub={fmtPct(summary.total_pnl_pct)} />
202
+ <MetricCard icon={Percent} label="Return" value={fmtPct(summary.total_pnl_pct)}
203
+ color={summary.total_pnl_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'}
204
+ sub={`${holdings.length} position${holdings.length !== 1 ? 's' : ''}`} />
205
+ </div>
206
+ </>
207
  )}
208
 
209
  {/* Add Holding Form */}
 
217
  <div className="form-group"><label>Average Price</label><input className="input" type="number" step="any" placeholder="150.00" value={form.avg_price} onChange={e => setForm({ ...form, avg_price: e.target.value })} required /></div>
218
  <div className="form-group"><label>Position Type</label><select value={form.position_type} onChange={e => setForm({ ...form, position_type: e.target.value })}><option value="long">Long</option><option value="short">Short</option></select></div>
219
  <div className="form-group"><label>Asset Class</label><select value={form.asset_class} onChange={e => setForm({ ...form, asset_class: e.target.value })}><option value="equity">Equity</option><option value="etf">ETF</option><option value="crypto">Crypto</option><option value="forex">Forex</option><option value="commodity">Commodity</option><option value="option">Option</option><option value="fixed_income">Fixed Income</option></select></div>
220
+ <div className="form-group"><label>Currency</label><select value={form.currency} onChange={e => setForm({ ...form, currency: e.target.value })}>{CURRENCY_OPTIONS.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}</select></div>
221
  <div className="form-group" style={{ display: 'flex', alignItems: 'flex-end' }}><button className="btn btn-primary" type="submit" style={{ width: '100%' }}>Add Position</button></div>
222
  </form>
223
  </div>
 
244
  <>
245
  {/* Positions Table */}
246
  <div className="card" style={{ marginBottom: '1.5rem' }}>
247
+
248
  <div className="card-header">
249
  <h3 style={{display:'flex',alignItems:'center',gap:'0.5rem'}}><BarChart3 size={15}/> Positions ({holdings.length})</h3>
250
  </div>
 
265
  </tr>
266
  </thead>
267
  <tbody>
268
+ {Array.from(marketGroups.entries()).map(([market, group]) => (
269
+ <React.Fragment key={`market-${market}`}>
270
+ {marketGroups.size > 1 && (
271
+ <tr key={`mkt-${market}`}>
272
+ <td colSpan={10} style={{ background: 'var(--bg-secondary)', padding: '0.4rem 1rem', fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--accent)', borderBottom: '2px solid var(--accent)' }}>
273
+ {market} Market — {getCurrencySymbol(group[0]?.currency || 'USD')} {group[0]?.currency || 'USD'}
274
+ <span style={{ float: 'right', color: 'var(--text-muted)', fontWeight: 500 }}>{group.length} position{group.length !== 1 ? 's' : ''} · {fmtC(group.reduce((s, h) => s + h.market_value, 0), group[0]?.currency)}</span>
275
+ </td>
276
+ </tr>
277
+ )}
278
+ {group.map(h => (
279
+ <tr key={h.id}>
280
+ <td style={{ fontWeight: 600, fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.ticker}</td>
281
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.quantity.toLocaleString()}</td>
282
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{fmtC(h.avg_price, h.currency)}</td>
283
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{fmtC(h.current_price, h.currency)}</td>
284
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 500 }}>{fmtC(h.market_value, h.currency)}</td>
285
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 600, color: h.pnl >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>
286
+ {h.pnl >= 0 ? '+' : ''}{fmtC(h.pnl, h.currency)}
287
+ </td>
288
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: 600, color: h.pnl_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>
289
+ {fmtPct(h.pnl_pct)}
290
+ </td>
291
+ <td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>{h.weight.toFixed(1)}%</td>
292
+ <td><span className={`badge ${h.position_type === 'long' ? 'badge-emerald' : 'badge-rose'}`} style={{fontSize:'0.65rem'}}>{h.position_type.toUpperCase()}</span></td>
293
+ <td>
294
+ <button onClick={() => handleDelete(h.id)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-muted)',padding:'0.25rem'}} title="Remove position">
295
+ <Trash2 size={13}/>
296
+ </button>
297
+ </td>
298
+ </tr>
299
+ ))}
300
+ </React.Fragment>
301
  ))}
302
  </tbody>
303
  </table>
 
315
  <Pie data={pieData} cx="50%" cy="50%" innerRadius={55} outerRadius={95} dataKey="value" label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} labelLine={{ stroke: '#8892a4', strokeWidth: 1 }}>
316
  {pieData.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
317
  </Pie>
318
+ <Tooltip formatter={(v: any) => formatCurrency(Number(v), primaryCurrency)} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem', boxShadow:'var(--shadow-md)' }} />
319
  </PieChart>
320
  </ResponsiveContainer>
321
  </div>
 
330
  <Pie data={assetClassData} cx="50%" cy="50%" innerRadius={55} outerRadius={95} dataKey="value" label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} labelLine={{ stroke: '#8892a4', strokeWidth: 1 }}>
331
  {assetClassData.map((_, i) => <Cell key={i} fill={COLORS[(i + 3) % COLORS.length]} />)}
332
  </Pie>
333
+ <Tooltip formatter={(v: any) => formatCurrency(Number(v), primaryCurrency)} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem' }} />
334
  </PieChart>
335
  </ResponsiveContainer>
336
  </div>
 
343
  <ResponsiveContainer>
344
  <BarChart data={holdings.map(h => ({ ticker: h.ticker, pnl: h.pnl, pnl_pct: h.pnl_pct }))} layout="vertical">
345
  <CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
346
+ <XAxis type="number" tick={{ fontSize: 10, fill: 'var(--chart-axis)' }} tickFormatter={v => formatCurrency(Number(v), primaryCurrency, { compact: true })} />
347
  <YAxis dataKey="ticker" type="category" tick={{ fontSize: 11, fill: 'var(--chart-axis)', fontWeight: 500 }} width={60} />
348
+ <Tooltip formatter={(v: any) => formatCurrency(Number(v), primaryCurrency)} contentStyle={{ background:'var(--bg-card)', border:'1px solid var(--border-color)', borderRadius:8, fontSize:'0.8rem' }} />
349
  <Bar dataKey="pnl" radius={[0, 4, 4, 0]}>
350
  {holdings.map((h, i) => <Cell key={i} fill={h.pnl >= 0 ? '#0a8f5c' : '#c23030'} />)}
351
  </Bar>
 
416
  </div>
417
  <p style={{ fontSize:'0.82rem', color:'var(--text-secondary)', marginBottom:'1.25rem' }}>{stressResult.scenario.description}</p>
418
  <div className="grid-4" style={{ marginBottom:'1.25rem' }}>
419
+ <MetricCard icon={DollarSign} label="Before" value={fmtC(stressResult.summary.total_before, primaryCurrency)} />
420
+ <MetricCard icon={DollarSign} label="After" value={fmtC(stressResult.summary.total_after, primaryCurrency)} />
421
+ <MetricCard icon={TrendingDown} label="Impact" value={fmtC(Math.abs(stressResult.summary.total_impact), primaryCurrency)} color="var(--red-negative)" />
422
  <MetricCard icon={Percent} label="Impact %" value={fmtPct(stressResult.summary.total_impact_pct)} color={stressResult.summary.total_impact_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)'} />
423
  </div>
424
  <div className="table-container">
 
428
  {stressResult.impacts.map((imp: any) => (
429
  <tr key={imp.ticker}>
430
  <td style={{ fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{imp.ticker}</td>
431
+ <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{fmtC(imp.current_value, primaryCurrency)}</td>
432
  <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem', color: imp.shock_pct >= 0 ? 'var(--green-positive)' : 'var(--red-negative)' }}>{fmtPct(imp.shock_pct)}</td>
433
+ <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem', color: imp.impact_value >= 0 ? 'var(--green-positive)' : 'var(--red-negative)', fontWeight:600 }}>{imp.impact_value >= 0 ? '+' : ''}{fmtC(imp.impact_value, primaryCurrency)}</td>
434
+ <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{fmtC(imp.new_value, primaryCurrency)}</td>
435
  <td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{imp.weight.toFixed(1)}%</td>
436
  </tr>
437
  ))}
 
456
  <>
457
  {hedgeData.risk_summary && (
458
  <div className="grid-4" style={{ marginBottom: '1.5rem' }}>
459
+ <MetricCard icon={DollarSign} label="Net Exposure" value={fmtC(hedgeData.risk_summary.net_exposure, primaryCurrency)} />
460
  <MetricCard icon={Shield} label="Hedge Ratio" value={`${hedgeData.risk_summary.hedge_ratio}%`} color="var(--accent)" />
461
  <MetricCard icon={BarChart3} label="Positions" value={hedgeData.risk_summary.position_count} />
462
  <MetricCard icon={Target} label="Diversification" value={`${hedgeData.risk_summary.diversification_score}/100`} color={hedgeData.risk_summary.diversification_score > 60 ? 'var(--green-positive)' : 'var(--amber-neutral)'} />
 
517
  <h3 style={{fontFamily:'var(--font-mono)',fontSize:'0.95rem'}}>{s.ticker}</h3>
518
  <div className="flex-gap">
519
  <span className="badge badge-primary">Vol: {s.volatility}%</span>
520
+ <span style={{ fontSize:'0.75rem', color:'var(--text-muted)' }}>{fmtC(s.current_price, primaryCurrency)} | {s.quantity} shares | {fmtC(s.market_value, primaryCurrency)}</span>
521
  </div>
522
  </div>
523
  {s.strategies?.map((st: any) => (
 
528
  <span style={{ fontSize:'0.68rem', fontWeight:700, textTransform:'uppercase', color:'var(--red-negative)', letterSpacing:'0.06em' }}>Protective Puts</span>
529
  {st.protective_puts?.map((p: any, i: number) => (
530
  <div key={i} style={{ marginTop:'0.5rem', fontSize:'0.78rem', display:'flex', justifyContent:'space-between' }}>
531
+ <span>{p.label} ({fmtC(p.strike, primaryCurrency)})</span>
532
+ <span style={{ fontWeight:600 }}>{fmtC(p.premium, primaryCurrency)} ({p.cost_pct}%)</span>
533
  </div>
534
  ))}
535
  </div>
 
537
  <span style={{ fontSize:'0.68rem', fontWeight:700, textTransform:'uppercase', color:'var(--green-positive)', letterSpacing:'0.06em' }}>Covered Calls</span>
538
  {st.covered_calls?.map((c: any, i: number) => (
539
  <div key={i} style={{ marginTop:'0.5rem', fontSize:'0.78rem', display:'flex', justifyContent:'space-between' }}>
540
+ <span>{c.label} ({fmtC(c.strike, primaryCurrency)})</span>
541
+ <span style={{ fontWeight:600, color:'var(--green-positive)' }}>+{fmtC(c.premium, primaryCurrency)} ({c.income_pct}%)</span>
542
  </div>
543
  ))}
544
  </div>
 
547
  <div style={{ marginTop:'0.5rem', fontSize:'0.78rem' }}>
548
  <div>{st.collar?.description}</div>
549
  <div style={{ marginTop:'0.375rem', fontWeight:600 }}>
550
+ Net Cost: <span style={{ color: st.collar?.net_cost > 0 ? 'var(--red-negative)' : 'var(--green-positive)' }}>{fmtC(st.collar?.net_cost, primaryCurrency)} ({st.collar?.net_cost_pct}%)</span>
551
  </div>
552
  </div>
553
  </div>
 
574
  <MetricCard icon={RefreshCw} label="Needs Rebalance" value={rebalanceData.summary?.needs_rebalance ? 'Yes' : 'No'} color={rebalanceData.summary?.needs_rebalance ? 'var(--red-negative)' : 'var(--green-positive)'} />
575
  <MetricCard icon={Activity} label="Total Drift" value={`${rebalanceData.summary?.total_drift?.toFixed(1)}%`} color="var(--amber-neutral)" />
576
  <MetricCard icon={BarChart3} label="Trades Needed" value={rebalanceData.summary?.trade_count} />
577
+ <MetricCard icon={DollarSign} label="Est. Cost" value={fmtC(rebalanceData.summary?.estimated_cost, primaryCurrency)} />
578
  </div>
579
  <div className="card" style={{ marginBottom:'1.5rem' }}>
580
  <div className="card-header"><h3>Current vs Target Allocation</h3></div>
 
588
  <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{a.current_pct}%</td>
589
  <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{a.target_pct}%</td>
590
  <td style={{ color: a.drift > 0 ? 'var(--red-negative)' : a.drift < 0 ? 'var(--green-positive)' : 'var(--text-muted)', fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{a.drift > 0 ? '+' : ''}{a.drift}%</td>
591
+ <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{fmtC(a.current_value, primaryCurrency)}</td>
592
+ <td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>{fmtC(a.target_value, primaryCurrency)}</td>
593
+ <td style={{ color: a.delta_value > 0 ? 'var(--green-positive)' : 'var(--red-negative)', fontWeight:600, fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>{a.delta_value > 0 ? '+' : ''}{fmtC(a.delta_value, primaryCurrency)}</td>
594
  </tr>
595
  ))}
596
  </tbody>
 
608
  <div style={{ fontWeight:600, textTransform:'capitalize', fontSize:'0.82rem' }}>{t.asset_class}</div>
609
  <div style={{ fontSize:'0.72rem', color:'var(--text-muted)' }}>{t.reason}</div>
610
  </div>
611
+ <div style={{ fontWeight:700, fontSize:'0.9rem', fontFamily:'var(--font-mono)' }}>{fmtC(t.amount, primaryCurrency)}</div>
612
  </div>
613
  ))}
614
  </div>
frontend/src/pages/PatternIntelligence.tsx ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { patternsAPI } from '../api/client';
3
+ import { formatCurrency, detectCurrencyFromTicker } from '../utils/currencyUtils';
4
+
5
+ /* ── SVG Icons (no emojis) ─────────────────────────────────────────────── */
6
+ const ScanIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" /></svg>;
7
+ const ChartIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg>;
8
+ const CatalogIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" /></svg>;
9
+ const AccuracyIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fillRule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg>;
10
+ const ArrowUp = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M3.293 9.707a1 1 0 010-1.414l6-6a1 1 0 011.414 0l6 6a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L4.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd" /></svg>;
11
+ const ArrowDown = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M16.707 10.293a1 1 0 010 1.414l-6 6a1 1 0 01-1.414 0l-6-6a1 1 0 111.414-1.414L9 14.586V3a1 1 0 012 0v11.586l4.293-4.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>;
12
+
13
+ const TABS = [
14
+ { id: 'scanner', label: 'Pattern Scanner', icon: <ScanIcon /> },
15
+ { id: 'catalog', label: 'Pattern Catalog', icon: <CatalogIcon /> },
16
+ { id: 'accuracy', label: 'Accuracy Report', icon: <AccuracyIcon /> },
17
+ ];
18
+
19
+ export default function PatternIntelligence() {
20
+ const [activeTab, setActiveTab] = useState('scanner');
21
+ const [ticker, setTicker] = useState('');
22
+ const [period, setPeriod] = useState('2y');
23
+ const [horizon, setHorizon] = useState(5);
24
+ const [loading, setLoading] = useState(false);
25
+ const [analysis, setAnalysis] = useState<any>(null);
26
+ const [catalog, setCatalog] = useState<any>(null);
27
+ const [accuracy, setAccuracy] = useState<any>(null);
28
+ const [error, setError] = useState('');
29
+
30
+ const handleAnalyze = async () => {
31
+ if (!ticker.trim()) return;
32
+ setLoading(true);
33
+ setError('');
34
+ try {
35
+ const { data } = await patternsAPI.analyze({ ticker: ticker.toUpperCase(), period, horizon });
36
+ setAnalysis(data);
37
+ } catch (err: any) {
38
+ setError(err.response?.data?.detail || 'Analysis failed');
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ };
43
+
44
+ const loadCatalog = async () => {
45
+ try {
46
+ const { data } = await patternsAPI.catalog();
47
+ setCatalog(data);
48
+ } catch (err) {
49
+ setError('Failed to load catalog');
50
+ }
51
+ };
52
+
53
+ const handleBacktestAccuracy = async () => {
54
+ if (!ticker.trim()) return;
55
+ setLoading(true);
56
+ setError('');
57
+ try {
58
+ const { data } = await patternsAPI.backtestAccuracy({ ticker: ticker.toUpperCase(), period: '5y', horizon });
59
+ setAccuracy(data);
60
+ } catch (err: any) {
61
+ setError(err.response?.data?.detail || 'Backtest failed');
62
+ } finally {
63
+ setLoading(false);
64
+ }
65
+ };
66
+
67
+ useEffect(() => {
68
+ if (activeTab === 'catalog' && !catalog) loadCatalog();
69
+ }, [activeTab]);
70
+
71
+ const directionColor = (d: string) => {
72
+ if (d === 'bullish' || d === 'strong_up') return 'var(--accent-green)';
73
+ if (d === 'bearish' || d === 'strong_down') return 'var(--accent-red, #ef4444)';
74
+ return 'var(--text-muted)';
75
+ };
76
+
77
+ const confidenceColor = (c: number) => {
78
+ if (c >= 0.65) return 'var(--accent-green)';
79
+ if (c >= 0.45) return 'var(--accent-yellow, #f59e0b)';
80
+ return 'var(--accent-red, #ef4444)';
81
+ };
82
+
83
+ return (
84
+ <div className="page-container" style={{ padding: '1.5rem 2rem' }}>
85
+ {/* Header */}
86
+ <div style={{ marginBottom: '1.5rem' }}>
87
+ <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
88
+ <ChartIcon /> Pattern Intelligence
89
+ </h1>
90
+ <p style={{ color: 'var(--text-muted)', margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
91
+ AI-powered candlestick pattern recognition with LightGBM ensemble prediction
92
+ </p>
93
+ </div>
94
+
95
+ {/* Tabs */}
96
+ <div className="qh-tabs" style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.25rem', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0' }}>
97
+ {TABS.map(tab => (
98
+ <button
99
+ key={tab.id}
100
+ onClick={() => setActiveTab(tab.id)}
101
+ style={{
102
+ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.6rem 1rem',
103
+ border: 'none', background: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
104
+ color: activeTab === tab.id ? 'var(--accent-green)' : 'var(--text-muted)',
105
+ borderBottom: activeTab === tab.id ? '2px solid var(--accent-green)' : '2px solid transparent',
106
+ transition: 'all 0.2s ease',
107
+ }}
108
+ >
109
+ {tab.icon} {tab.label}
110
+ </button>
111
+ ))}
112
+ </div>
113
+
114
+ {/* Search Bar */}
115
+ {(activeTab === 'scanner' || activeTab === 'accuracy') && (
116
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
117
+ <input
118
+ type="text"
119
+ value={ticker}
120
+ onChange={e => setTicker(e.target.value.toUpperCase())}
121
+ placeholder="Enter ticker (e.g., AAPL, RELIANCE.NS)"
122
+ onKeyDown={e => e.key === 'Enter' && (activeTab === 'scanner' ? handleAnalyze() : handleBacktestAccuracy())}
123
+ style={{
124
+ flex: '1 1 200px', minWidth: 180, padding: '0.55rem 0.75rem',
125
+ border: '1px solid var(--border-subtle)', borderRadius: '0.5rem',
126
+ background: 'var(--bg-secondary)', color: 'var(--text-primary)',
127
+ fontSize: '0.85rem', outline: 'none',
128
+ }}
129
+ />
130
+ <select value={period} onChange={e => setPeriod(e.target.value)} style={{
131
+ padding: '0.55rem 0.75rem', border: '1px solid var(--border-subtle)', borderRadius: '0.5rem',
132
+ background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: '0.85rem',
133
+ }}>
134
+ <option value="1y">1 Year</option>
135
+ <option value="2y">2 Years</option>
136
+ <option value="3y">3 Years</option>
137
+ <option value="5y">5 Years</option>
138
+ </select>
139
+ <select value={horizon} onChange={e => setHorizon(Number(e.target.value))} style={{
140
+ padding: '0.55rem 0.75rem', border: '1px solid var(--border-subtle)', borderRadius: '0.5rem',
141
+ background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: '0.85rem',
142
+ }}>
143
+ <option value={1}>1-Day Horizon</option>
144
+ <option value={3}>3-Day Horizon</option>
145
+ <option value={5}>5-Day Horizon</option>
146
+ <option value={10}>10-Day Horizon</option>
147
+ <option value={20}>20-Day Horizon</option>
148
+ </select>
149
+ <button
150
+ onClick={activeTab === 'scanner' ? handleAnalyze : handleBacktestAccuracy}
151
+ disabled={loading || !ticker.trim()}
152
+ style={{
153
+ padding: '0.55rem 1.2rem', borderRadius: '0.5rem', border: 'none',
154
+ background: 'var(--accent-green)', color: '#fff', fontWeight: 600,
155
+ fontSize: '0.85rem', cursor: loading ? 'wait' : 'pointer',
156
+ opacity: loading || !ticker.trim() ? 0.6 : 1,
157
+ }}
158
+ >
159
+ {loading ? 'Analyzing...' : activeTab === 'scanner' ? 'Analyze' : 'Run Accuracy Test'}
160
+ </button>
161
+ </div>
162
+ )}
163
+
164
+ {error && (
165
+ <div style={{ padding: '0.75rem 1rem', borderRadius: '0.5rem', background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: '0.8rem', marginBottom: '1rem', border: '1px solid rgba(239,68,68,0.2)' }}>
166
+ {error}
167
+ </div>
168
+ )}
169
+
170
+ {/* ── Scanner Tab ──────────────────────────────────────────────── */}
171
+ {activeTab === 'scanner' && analysis && (
172
+ <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
173
+ {/* Prediction Card */}
174
+ <div className="card" style={{ padding: '1.25rem', gridColumn: 'span 2' }}>
175
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
176
+ <div>
177
+ <div style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', fontWeight: 600 }}>Prediction</div>
178
+ <div style={{ fontSize: '1.5rem', fontWeight: 700, color: directionColor(analysis.prediction), display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
179
+ {analysis.prediction === 'strong_up' && <ArrowUp />}
180
+ {analysis.prediction === 'strong_down' && <ArrowDown />}
181
+ {analysis.prediction_label}
182
+ </div>
183
+ </div>
184
+ <div style={{ textAlign: 'right' }}>
185
+ <div style={{ fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)', fontWeight: 600 }}>Confidence</div>
186
+ <div style={{ fontSize: '1.5rem', fontWeight: 700, color: confidenceColor(analysis.confidence) }}>
187
+ {(analysis.confidence * 100).toFixed(1)}%
188
+ </div>
189
+ <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{analysis.confidence_level} confidence</div>
190
+ </div>
191
+ </div>
192
+
193
+ {/* Probability Bar */}
194
+ <div style={{ marginBottom: '0.75rem' }}>
195
+ <div style={{ display: 'flex', fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '0.25rem' }}>
196
+ <span>Bearish</span><span style={{ marginLeft: 'auto' }}>Bullish</span>
197
+ </div>
198
+ <div style={{ display: 'flex', height: 8, borderRadius: 4, overflow: 'hidden', gap: 1 }}>
199
+ <div style={{ width: `${analysis.probabilities.strong_down * 100}%`, background: '#ef4444' }} />
200
+ <div style={{ width: `${analysis.probabilities.neutral * 100}%`, background: '#6b7280' }} />
201
+ <div style={{ width: `${analysis.probabilities.strong_up * 100}%`, background: 'var(--accent-green)' }} />
202
+ </div>
203
+ <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.65rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
204
+ <span>{(analysis.probabilities.strong_down * 100).toFixed(1)}%</span>
205
+ <span>{(analysis.probabilities.neutral * 100).toFixed(1)}%</span>
206
+ <span>{(analysis.probabilities.strong_up * 100).toFixed(1)}%</span>
207
+ </div>
208
+ </div>
209
+
210
+ {/* Stats Row */}
211
+ <div style={{ display: 'flex', gap: '1.5rem', fontSize: '0.8rem', flexWrap: 'wrap' }}>
212
+ <div><span style={{ color: 'var(--text-muted)' }}>Price: </span><strong>{formatCurrency(analysis.current_price, detectCurrencyFromTicker(ticker))}</strong></div>
213
+ <div><span style={{ color: 'var(--text-muted)' }}>Expected Return: </span><strong style={{ color: directionColor(analysis.prediction) }}>{analysis.expected_return_pct > 0 ? '+' : ''}{analysis.expected_return_pct}%</strong></div>
214
+ <div><span style={{ color: 'var(--text-muted)' }}>Horizon: </span><strong>{analysis.horizon_days}D</strong></div>
215
+ <div><span style={{ color: 'var(--text-muted)' }}>Accuracy: </span><strong>{(analysis.model_metrics?.accuracy * 100).toFixed(1)}%</strong></div>
216
+ <div><span style={{ color: 'var(--text-muted)' }}>F1: </span><strong>{analysis.model_metrics?.f1?.toFixed(4)}</strong></div>
217
+ </div>
218
+ </div>
219
+
220
+ {/* Hedge Recommendation Card */}
221
+ {analysis.hedge_recommendation && (
222
+ <div className="card" style={{ padding: '1.25rem', gridColumn: 'span 2', borderLeft: `4px solid ${analysis.hedge_recommendation.urgency === 'high' ? '#ef4444' : analysis.hedge_recommendation.urgency === 'medium' ? '#f59e0b' : 'var(--accent-green)'}` }}>
223
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
224
+ <div style={{ fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)' }}>Hedge Recommendation</div>
225
+ <span style={{
226
+ fontSize: '0.65rem', padding: '0.15rem 0.5rem', borderRadius: 20, fontWeight: 700,
227
+ background: analysis.hedge_recommendation.urgency === 'high' ? 'rgba(239,68,68,0.15)' : analysis.hedge_recommendation.urgency === 'medium' ? 'rgba(245,158,11,0.15)' : 'rgba(16,185,129,0.15)',
228
+ color: analysis.hedge_recommendation.urgency === 'high' ? '#ef4444' : analysis.hedge_recommendation.urgency === 'medium' ? '#f59e0b' : 'var(--accent-green)',
229
+ }}>{analysis.hedge_recommendation.action?.replace(/_/g, ' ').toUpperCase()}</span>
230
+ </div>
231
+ <div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', marginBottom: '0.75rem' }}>
232
+ <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'var(--bg-tertiary, var(--bg-secondary))' }}>
233
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Suggested Hedge</div>
234
+ <div style={{ fontSize: '1.1rem', fontWeight: 700 }}>{analysis.hedge_recommendation.suggested_hedge_pct}%</div>
235
+ </div>
236
+ <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'var(--bg-tertiary, var(--bg-secondary))' }}>
237
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Pattern Consensus</div>
238
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: directionColor(analysis.hedge_recommendation.pattern_consensus === 'bearish' ? 'bearish' : analysis.hedge_recommendation.pattern_consensus === 'bullish' ? 'bullish' : 'neutral') }}>
239
+ {analysis.hedge_recommendation.pattern_consensus?.charAt(0).toUpperCase() + analysis.hedge_recommendation.pattern_consensus?.slice(1)}
240
+ </div>
241
+ </div>
242
+ <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'var(--bg-tertiary, var(--bg-secondary))' }}>
243
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Bearish Signals</div>
244
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#ef4444' }}>{analysis.hedge_recommendation.bearish_pattern_count}</div>
245
+ </div>
246
+ <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'var(--bg-tertiary, var(--bg-secondary))' }}>
247
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Bullish Signals</div>
248
+ <div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--accent-green)' }}>{analysis.hedge_recommendation.bullish_pattern_count}</div>
249
+ </div>
250
+ </div>
251
+ <div style={{ padding: '0.6rem 0.85rem', borderRadius: '0.4rem', background: 'var(--bg-secondary)', fontSize: '0.78rem', color: 'var(--text-secondary)', lineHeight: 1.5, marginBottom: analysis.hedge_recommendation.instruments?.length ? '0.75rem' : 0 }}>
252
+ {analysis.hedge_recommendation.rationale}
253
+ </div>
254
+ {analysis.hedge_recommendation.instruments?.length > 0 && (
255
+ <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
256
+ {analysis.hedge_recommendation.instruments.map((inst: string, i: number) => (
257
+ <span key={i} style={{ fontSize: '0.7rem', padding: '0.2rem 0.5rem', borderRadius: 4, background: 'rgba(99,102,241,0.1)', color: '#6366f1', fontWeight: 600 }}>{inst}</span>
258
+ ))}
259
+ </div>
260
+ )}
261
+ </div>
262
+ )}
263
+
264
+ {/* Detected Patterns */}
265
+ <div className="card" style={{ padding: '1.25rem' }}>
266
+ <div style={{ fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
267
+ Detected Patterns ({analysis.detected_patterns?.length || 0})
268
+ </div>
269
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxHeight: 350, overflow: 'auto' }}>
270
+ {(analysis.detected_patterns || []).map((p: any, i: number) => (
271
+ <div key={i} style={{
272
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
273
+ padding: '0.5rem 0.75rem', borderRadius: '0.4rem',
274
+ background: 'var(--bg-tertiary, var(--bg-secondary))',
275
+ border: '1px solid var(--border-subtle)',
276
+ }}>
277
+ <div>
278
+ <div style={{ fontSize: '0.8rem', fontWeight: 600 }}>{p.name}</div>
279
+ <div style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{p.category}</div>
280
+ </div>
281
+ <div style={{ textAlign: 'right' }}>
282
+ <div style={{ fontSize: '0.75rem', fontWeight: 600, color: directionColor(p.direction) }}>
283
+ {p.direction === 'bullish' ? 'Bullish' : p.direction === 'bearish' ? 'Bearish' : 'Neutral'}
284
+ </div>
285
+ <div style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>
286
+ {(p.reliability * 100).toFixed(0)}% reliability
287
+ </div>
288
+ </div>
289
+ </div>
290
+ ))}
291
+ {(!analysis.detected_patterns || analysis.detected_patterns.length === 0) && (
292
+ <div style={{ fontSize: '0.8rem', color: 'var(--text-muted)', textAlign: 'center', padding: '1rem' }}>
293
+ No patterns detected in recent candles
294
+ </div>
295
+ )}
296
+ </div>
297
+ </div>
298
+
299
+ {/* Top Features */}
300
+ <div className="card" style={{ padding: '1.25rem' }}>
301
+ <div style={{ fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
302
+ Top Feature Importances
303
+ </div>
304
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
305
+ {(analysis.top_features || []).slice(0, 10).map((f: any, i: number) => {
306
+ const maxImp = analysis.top_features[0]?.importance || 1;
307
+ return (
308
+ <div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
309
+ <span style={{ width: 130, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }}>
310
+ {f.name}
311
+ </span>
312
+ <div style={{ flex: 1, height: 6, borderRadius: 3, background: 'var(--bg-tertiary, var(--bg-secondary))' }}>
313
+ <div style={{ width: `${(f.importance / maxImp) * 100}%`, height: '100%', borderRadius: 3, background: 'var(--accent-green)' }} />
314
+ </div>
315
+ <span style={{ width: 45, textAlign: 'right', fontSize: '0.65rem', color: 'var(--text-muted)' }}>
316
+ {f.importance.toFixed(1)}
317
+ </span>
318
+ </div>
319
+ );
320
+ })}
321
+ </div>
322
+ </div>
323
+
324
+ {/* Advanced Features */}
325
+ <div className="card" style={{ padding: '1.25rem', gridColumn: 'span 2' }}>
326
+ <div style={{ fontSize: '0.75rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
327
+ Advanced Mathematical Features
328
+ </div>
329
+ <div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
330
+ {analysis.advanced_features && Object.entries(analysis.advanced_features).filter(([_, v]) => typeof v === 'number').map(([key, val]: [string, any]) => (
331
+ <div key={key} style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'var(--bg-tertiary, var(--bg-secondary))', border: '1px solid var(--border-subtle)' }}>
332
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)', letterSpacing: '0.03em' }}>
333
+ {key.replace(/_/g, ' ')}
334
+ </div>
335
+ <div style={{ fontSize: '1rem', fontWeight: 700 }}>{typeof val === 'number' ? val.toFixed(4) : val}</div>
336
+ </div>
337
+ ))}
338
+ </div>
339
+ </div>
340
+ </div>
341
+ )}
342
+
343
+ {/* ── Catalog Tab ──────────────────────────────────────────────── */}
344
+ {activeTab === 'catalog' && catalog && (
345
+ <div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
346
+ {(catalog.patterns || []).map((p: any, i: number) => (
347
+ <div key={i} className="card" style={{ padding: '0.75rem 1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
348
+ <div style={{ flex: 1 }}>
349
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.2rem' }}>
350
+ <span style={{ fontSize: '0.85rem', fontWeight: 600 }}>{p.name}</span>
351
+ <span style={{
352
+ fontSize: '0.6rem', padding: '0.1rem 0.4rem', borderRadius: 20,
353
+ background: p.category === 'single' ? 'rgba(59,130,246,0.1)' : p.category === 'multi' ? 'rgba(168,85,247,0.1)' : 'rgba(251,146,60,0.1)',
354
+ color: p.category === 'single' ? '#3b82f6' : p.category === 'multi' ? '#a855f7' : '#fb923c',
355
+ fontWeight: 600,
356
+ }}>{p.category}</span>
357
+ </div>
358
+ <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>{p.description}</div>
359
+ </div>
360
+ <div style={{ textAlign: 'right', minWidth: 80, marginLeft: '0.75rem' }}>
361
+ <div style={{ fontSize: '0.75rem', fontWeight: 600, color: directionColor(p.direction) }}>
362
+ {p.direction === 'bullish' ? 'Bullish' : p.direction === 'bearish' ? 'Bearish' : 'Neutral'}
363
+ </div>
364
+ <div style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>{(p.reliability * 100).toFixed(0)}% reliable</div>
365
+ </div>
366
+ </div>
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ {/* ── Accuracy Tab ─────────────────────────────────────────────── */}
372
+ {activeTab === 'accuracy' && accuracy && (
373
+ <div>
374
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
375
+ <div className="card" style={{ padding: '0.75rem 1rem', flex: 1, minWidth: 120 }}>
376
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Bars Analyzed</div>
377
+ <div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{accuracy.total_bars_analyzed}</div>
378
+ </div>
379
+ <div className="card" style={{ padding: '0.75rem 1rem', flex: 1, minWidth: 120 }}>
380
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Patterns Found</div>
381
+ <div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{accuracy.patterns_found}</div>
382
+ </div>
383
+ <div className="card" style={{ padding: '0.75rem 1rem', flex: 1, minWidth: 120 }}>
384
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Horizon</div>
385
+ <div style={{ fontSize: '1.25rem', fontWeight: 700 }}>{accuracy.horizon_days}D</div>
386
+ </div>
387
+ </div>
388
+
389
+ <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
390
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
391
+ <thead>
392
+ <tr style={{ background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-subtle)' }}>
393
+ <th style={{ padding: '0.6rem 1rem', textAlign: 'left', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Pattern</th>
394
+ <th style={{ padding: '0.6rem 0.75rem', textAlign: 'center', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Direction</th>
395
+ <th style={{ padding: '0.6rem 0.75rem', textAlign: 'center', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Occurrences</th>
396
+ <th style={{ padding: '0.6rem 0.75rem', textAlign: 'center', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Win Rate</th>
397
+ <th style={{ padding: '0.6rem 0.75rem', textAlign: 'center', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Avg Return</th>
398
+ <th style={{ padding: '0.6rem 0.75rem', textAlign: 'center', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', color: 'var(--text-muted)' }}>Actual vs Theoretical</th>
399
+ </tr>
400
+ </thead>
401
+ <tbody>
402
+ {(accuracy.accuracy_report || []).map((r: any, i: number) => (
403
+ <tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)' }}>
404
+ <td style={{ padding: '0.5rem 1rem', fontWeight: 600 }}>{r.pattern}</td>
405
+ <td style={{ padding: '0.5rem 0.75rem', textAlign: 'center', color: directionColor(r.direction) }}>{r.direction}</td>
406
+ <td style={{ padding: '0.5rem 0.75rem', textAlign: 'center' }}>{r.occurrences}</td>
407
+ <td style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, color: r.win_rate >= 0.5 ? 'var(--accent-green)' : '#ef4444' }}>
408
+ {(r.win_rate * 100).toFixed(1)}%
409
+ </td>
410
+ <td style={{ padding: '0.5rem 0.75rem', textAlign: 'center', color: r.avg_return_pct >= 0 ? 'var(--accent-green)' : '#ef4444' }}>
411
+ {r.avg_return_pct >= 0 ? '+' : ''}{r.avg_return_pct.toFixed(2)}%
412
+ </td>
413
+ <td style={{ padding: '0.5rem 0.75rem', textAlign: 'center', color: r.actual_vs_theoretical >= 0 ? 'var(--accent-green)' : '#ef4444' }}>
414
+ {r.actual_vs_theoretical >= 0 ? '+' : ''}{(r.actual_vs_theoretical * 100).toFixed(1)}%
415
+ </td>
416
+ </tr>
417
+ ))}
418
+ </tbody>
419
+ </table>
420
+ </div>
421
+ </div>
422
+ )}
423
+
424
+ {/* Empty state */}
425
+ {activeTab === 'scanner' && !analysis && !loading && (
426
+ <div style={{ textAlign: 'center', padding: '4rem 2rem', color: 'var(--text-muted)' }}>
427
+ <ChartIcon />
428
+ <p style={{ fontSize: '0.9rem', marginTop: '0.75rem' }}>Enter a ticker symbol and click Analyze to detect candlestick patterns and get AI predictions</p>
429
+ <p style={{ fontSize: '0.75rem' }}>Supports all markets: US equities, Indian stocks (.NS), crypto, and more</p>
430
+ </div>
431
+ )}
432
+ </div>
433
+ );
434
+ }
frontend/src/pages/PineScriptLab.tsx ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { pinescriptAPI } from '../api/client';
3
+ import { formatCurrency, detectCurrencyFromTicker } from '../utils/currencyUtils';
4
+
5
+ /* ── SVG Icons ─────────────────────────────────────────────────────────── */
6
+ const CodeIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path fillRule="evenodd" d="M12.316 3.051a1 1 0 01.633 1.265l-4 12a1 1 0 11-1.898-.632l4-12a1 1 0 011.265-.633zM5.707 6.293a1 1 0 010 1.414L3.414 10l2.293 2.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0zm8.586 0a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 11-1.414-1.414L16.586 10l-2.293-2.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>;
7
+ const TemplateIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18"><path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" /></svg>;
8
+ const PlayIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" /></svg>;
9
+ const CopyIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M8 2a1 1 0 000 2h2a1 1 0 100-2H8z" /><path d="M3 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v6h-4.586l1.293-1.293a1 1 0 00-1.414-1.414l-3 3a1 1 0 000 1.414l3 3a1 1 0 001.414-1.414L10.414 13H15v3a2 2 0 01-2 2H5a2 2 0 01-2-2V5z" /></svg>;
10
+ const DownloadIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" /></svg>;
11
+ const CheckIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>;
12
+ const XIcon = () => <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /></svg>;
13
+
14
+ const CATEGORY_COLORS: Record<string, string> = {
15
+ 'Momentum': '#3b82f6',
16
+ 'Mean Reversion': '#a855f7',
17
+ 'Volatility': '#f59e0b',
18
+ 'Trend Following': '#10b981',
19
+ 'Oscillator': '#ec4899',
20
+ 'Statistical': '#6366f1',
21
+ 'Risk-Managed': '#14b8a6',
22
+ 'Advanced': '#f97316',
23
+ 'Intraday': '#06b6d4',
24
+ 'Multi-Signal': '#8b5cf6',
25
+ 'Hedging': '#ef4444',
26
+ };
27
+
28
+ export default function PineScriptLab() {
29
+ const [mode, setMode] = useState<'generate' | 'templates'>('templates');
30
+ const [description, setDescription] = useState('');
31
+ const [code, setCode] = useState('');
32
+ const [templates, setTemplates] = useState<any[]>([]);
33
+ const [backtestTicker, setBacktestTicker] = useState('SPY');
34
+ const [backtestPeriod, setBacktestPeriod] = useState('3y');
35
+ const [loading, setLoading] = useState(false);
36
+ const [generating, setGenerating] = useState(false);
37
+ const [results, setResults] = useState<any>(null);
38
+ const [validation, setValidation] = useState<any>(null);
39
+ const [error, setError] = useState('');
40
+ const [copied, setCopied] = useState(false);
41
+
42
+ useEffect(() => {
43
+ loadTemplates();
44
+ }, []);
45
+
46
+ const loadTemplates = async () => {
47
+ try {
48
+ const { data } = await pinescriptAPI.templates();
49
+ setTemplates(data.templates || []);
50
+ } catch (err) {
51
+ console.error('Failed to load templates');
52
+ }
53
+ };
54
+
55
+ const handleGenerate = async () => {
56
+ if (!description.trim()) return;
57
+ setGenerating(true);
58
+ setError('');
59
+ try {
60
+ const { data } = await pinescriptAPI.generate({ description });
61
+ setCode(data.code || '');
62
+ setValidation(null);
63
+ setResults(null);
64
+ } catch (err: any) {
65
+ setError(err.response?.data?.detail || 'Generation failed');
66
+ } finally {
67
+ setGenerating(false);
68
+ }
69
+ };
70
+
71
+ const handleTemplateSelect = async (templateId: string) => {
72
+ setGenerating(true);
73
+ setError('');
74
+ try {
75
+ const { data } = await pinescriptAPI.generateFromTemplate({ template_id: templateId });
76
+ setCode(data.code || '');
77
+ setValidation(null);
78
+ setResults(null);
79
+ } catch (err: any) {
80
+ setError(err.response?.data?.detail || 'Template load failed');
81
+ } finally {
82
+ setGenerating(false);
83
+ }
84
+ };
85
+
86
+ const handleValidate = async () => {
87
+ if (!code.trim()) return;
88
+ try {
89
+ const { data } = await pinescriptAPI.validate({ code });
90
+ setValidation(data);
91
+ } catch (err) {
92
+ setError('Validation failed');
93
+ }
94
+ };
95
+
96
+ const handleBacktest = async () => {
97
+ if (!code.trim()) return;
98
+ setLoading(true);
99
+ setError('');
100
+ try {
101
+ const { data } = await pinescriptAPI.backtest({
102
+ code, ticker: backtestTicker, period: backtestPeriod,
103
+ });
104
+ setResults(data);
105
+ } catch (err: any) {
106
+ setError(err.response?.data?.detail || 'Backtest failed');
107
+ } finally {
108
+ setLoading(false);
109
+ }
110
+ };
111
+
112
+ const handleCopy = () => {
113
+ navigator.clipboard.writeText(code);
114
+ setCopied(true);
115
+ setTimeout(() => setCopied(false), 2000);
116
+ };
117
+
118
+ const handleDownload = () => {
119
+ const blob = new Blob([code], { type: 'text/plain' });
120
+ const url = URL.createObjectURL(blob);
121
+ const a = document.createElement('a');
122
+ a.href = url;
123
+ a.download = 'strategy.pine';
124
+ a.click();
125
+ URL.revokeObjectURL(url);
126
+ };
127
+
128
+ const metricColor = (val: number, positive: boolean = true) => {
129
+ if (positive) return val >= 0 ? 'var(--accent-green)' : '#ef4444';
130
+ return val >= 0 ? '#ef4444' : 'var(--accent-green)';
131
+ };
132
+
133
+ return (
134
+ <div className="page-container" style={{ padding: '1.5rem 2rem' }}>
135
+ {/* Header */}
136
+ <div style={{ marginBottom: '1.5rem' }}>
137
+ <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
138
+ <CodeIcon /> Pine Script Lab
139
+ </h1>
140
+ <p style={{ color: 'var(--text-muted)', margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
141
+ Generate, validate, and backtest TradingView Pine Script v5 strategies
142
+ </p>
143
+ </div>
144
+
145
+ <div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
146
+ {/* ── Left Panel: Code Generation ────────────────────────────── */}
147
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
148
+ {/* Mode Toggle */}
149
+ <div style={{ display: 'flex', gap: '0.25rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', padding: '0.2rem' }}>
150
+ <button
151
+ onClick={() => setMode('templates')}
152
+ style={{
153
+ flex: 1, padding: '0.45rem', border: 'none', borderRadius: '0.4rem', cursor: 'pointer',
154
+ fontSize: '0.8rem', fontWeight: 600, transition: 'all 0.2s',
155
+ background: mode === 'templates' ? 'var(--accent-green)' : 'transparent',
156
+ color: mode === 'templates' ? '#fff' : 'var(--text-muted)',
157
+ }}
158
+ >
159
+ <TemplateIcon /> Templates
160
+ </button>
161
+ <button
162
+ onClick={() => setMode('generate')}
163
+ style={{
164
+ flex: 1, padding: '0.45rem', border: 'none', borderRadius: '0.4rem', cursor: 'pointer',
165
+ fontSize: '0.8rem', fontWeight: 600, transition: 'all 0.2s',
166
+ background: mode === 'generate' ? 'var(--accent-green)' : 'transparent',
167
+ color: mode === 'generate' ? '#fff' : 'var(--text-muted)',
168
+ }}
169
+ >
170
+ AI Generate
171
+ </button>
172
+ </div>
173
+
174
+ {/* Template Browser */}
175
+ {mode === 'templates' && (
176
+ <div style={{ display: 'grid', gap: '0.4rem', maxHeight: 220, overflow: 'auto', gridTemplateColumns: '1fr 1fr' }}>
177
+ {templates.map((t: any) => (
178
+ <button
179
+ key={t.id}
180
+ onClick={() => handleTemplateSelect(t.id)}
181
+ style={{
182
+ padding: '0.6rem 0.75rem', borderRadius: '0.5rem', cursor: 'pointer',
183
+ border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)',
184
+ textAlign: 'left', transition: 'all 0.15s',
185
+ color: 'var(--text-primary)',
186
+ }}
187
+ onMouseOver={e => (e.currentTarget.style.borderColor = 'var(--accent-green)')}
188
+ onMouseOut={e => (e.currentTarget.style.borderColor = 'var(--border-subtle)')}
189
+ >
190
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.2rem' }}>
191
+ <span style={{ fontSize: '0.8rem', fontWeight: 600 }}>{t.name}</span>
192
+ <span style={{
193
+ fontSize: '0.55rem', padding: '0.1rem 0.35rem', borderRadius: 20,
194
+ background: `${CATEGORY_COLORS[t.category] || '#6b7280'}22`,
195
+ color: CATEGORY_COLORS[t.category] || '#6b7280', fontWeight: 600,
196
+ }}>{t.category}</span>
197
+ </div>
198
+ <div style={{ fontSize: '0.65rem', color: 'var(--text-muted)', lineHeight: 1.3 }}>
199
+ {t.description.length > 80 ? t.description.slice(0, 80) + '...' : t.description}
200
+ </div>
201
+ </button>
202
+ ))}
203
+ </div>
204
+ )}
205
+
206
+ {/* NL Generator */}
207
+ {mode === 'generate' && (
208
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
209
+ <textarea
210
+ value={description}
211
+ onChange={e => setDescription(e.target.value)}
212
+ placeholder="Describe your strategy in natural language...&#10;e.g., 'Create a strategy that buys when RSI is below 30 and the price is above the 200-day SMA, sells when RSI goes above 70'"
213
+ style={{
214
+ width: '100%', minHeight: 100, padding: '0.75rem', borderRadius: '0.5rem',
215
+ border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)',
216
+ color: 'var(--text-primary)', fontSize: '0.8rem', resize: 'vertical',
217
+ fontFamily: 'inherit', lineHeight: 1.5,
218
+ }}
219
+ />
220
+ <button
221
+ onClick={handleGenerate}
222
+ disabled={generating || !description.trim()}
223
+ style={{
224
+ padding: '0.55rem 1rem', borderRadius: '0.5rem', border: 'none',
225
+ background: 'var(--accent-green)', color: '#fff', fontWeight: 600,
226
+ fontSize: '0.8rem', cursor: generating ? 'wait' : 'pointer',
227
+ opacity: generating || !description.trim() ? 0.6 : 1,
228
+ }}
229
+ >
230
+ {generating ? 'Generating...' : 'Generate Pine Script'}
231
+ </button>
232
+ </div>
233
+ )}
234
+
235
+ {/* Code Editor */}
236
+ <div style={{ position: 'relative', flex: 1 }}>
237
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.3rem' }}>
238
+ <span style={{ fontSize: '0.7rem', fontWeight: 600, textTransform: 'uppercase', color: 'var(--text-muted)' }}>Pine Script v5 Code</span>
239
+ <div style={{ display: 'flex', gap: '0.3rem' }}>
240
+ {validation && (
241
+ <span style={{
242
+ display: 'flex', alignItems: 'center', gap: '0.2rem', fontSize: '0.65rem', fontWeight: 600,
243
+ color: validation.valid ? 'var(--accent-green)' : '#ef4444',
244
+ }}>
245
+ {validation.valid ? <><CheckIcon /> Valid</> : <><XIcon /> {validation.errors?.length} Error(s)</>}
246
+ </span>
247
+ )}
248
+ <button onClick={handleValidate} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer' }}>Validate</button>
249
+ <button onClick={handleCopy} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
250
+ <CopyIcon /> {copied ? 'Copied' : 'Copy'}
251
+ </button>
252
+ <button onClick={handleDownload} disabled={!code} style={{ padding: '0.2rem 0.5rem', borderRadius: '0.3rem', border: '1px solid var(--border-subtle)', background: 'transparent', color: 'var(--text-muted)', fontSize: '0.65rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.2rem' }}>
253
+ <DownloadIcon /> .pine
254
+ </button>
255
+ </div>
256
+ </div>
257
+ <textarea
258
+ value={code}
259
+ onChange={e => { setCode(e.target.value); setValidation(null); }}
260
+ style={{
261
+ width: '100%', minHeight: 280, padding: '0.75rem', borderRadius: '0.5rem',
262
+ border: '1px solid var(--border-subtle)', background: 'var(--bg-tertiary, #0d1117)',
263
+ color: 'var(--text-primary)', fontSize: '0.75rem', fontFamily: '"JetBrains Mono", "Fira Code", monospace',
264
+ lineHeight: 1.6, resize: 'vertical', tabSize: 4,
265
+ }}
266
+ placeholder="// Your Pine Script v5 code will appear here..."
267
+ spellCheck={false}
268
+ />
269
+ </div>
270
+
271
+ {/* Validation Errors */}
272
+ {validation && !validation.valid && (
273
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
274
+ {(validation.errors || []).map((e: any, i: number) => (
275
+ <div key={i} style={{ fontSize: '0.7rem', padding: '0.3rem 0.5rem', borderRadius: '0.3rem', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.2)' }}>
276
+ {e.line > 0 && `Line ${e.line}: `}{e.message}
277
+ </div>
278
+ ))}
279
+ {(validation.warnings || []).map((w: any, i: number) => (
280
+ <div key={i} style={{ fontSize: '0.7rem', padding: '0.3rem 0.5rem', borderRadius: '0.3rem', background: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}>
281
+ {w.line > 0 && `Line ${w.line}: `}{w.message}
282
+ </div>
283
+ ))}
284
+ </div>
285
+ )}
286
+ </div>
287
+
288
+ {/* ── Right Panel: Backtest Results ──────────────────────────── */}
289
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
290
+ {/* Backtest Controls */}
291
+ <div className="card" style={{ padding: '0.75rem 1rem' }}>
292
+ <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
293
+ <input
294
+ type="text"
295
+ value={backtestTicker}
296
+ onChange={e => setBacktestTicker(e.target.value.toUpperCase())}
297
+ placeholder="Ticker"
298
+ style={{
299
+ width: 100, padding: '0.45rem 0.6rem', borderRadius: '0.4rem',
300
+ border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)',
301
+ color: 'var(--text-primary)', fontSize: '0.8rem',
302
+ }}
303
+ />
304
+ <select value={backtestPeriod} onChange={e => setBacktestPeriod(e.target.value)} style={{
305
+ padding: '0.45rem 0.6rem', borderRadius: '0.4rem',
306
+ border: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)',
307
+ color: 'var(--text-primary)', fontSize: '0.8rem',
308
+ }}>
309
+ <option value="1y">1 Year</option>
310
+ <option value="2y">2 Years</option>
311
+ <option value="3y">3 Years</option>
312
+ <option value="5y">5 Years</option>
313
+ </select>
314
+ <button
315
+ onClick={handleBacktest}
316
+ disabled={loading || !code.trim()}
317
+ style={{
318
+ display: 'flex', alignItems: 'center', gap: '0.3rem',
319
+ padding: '0.45rem 1rem', borderRadius: '0.4rem', border: 'none',
320
+ background: 'var(--accent-green)', color: '#fff', fontWeight: 600,
321
+ fontSize: '0.8rem', cursor: loading ? 'wait' : 'pointer',
322
+ opacity: loading || !code.trim() ? 0.6 : 1,
323
+ }}
324
+ >
325
+ <PlayIcon /> {loading ? 'Running...' : 'Run Backtest'}
326
+ </button>
327
+ </div>
328
+ </div>
329
+
330
+ {error && (
331
+ <div style={{ padding: '0.5rem 0.75rem', borderRadius: '0.4rem', background: 'rgba(239,68,68,0.1)', color: '#ef4444', fontSize: '0.75rem', border: '1px solid rgba(239,68,68,0.2)' }}>
332
+ {error}
333
+ </div>
334
+ )}
335
+
336
+ {/* Results */}
337
+ {results && (
338
+ <>
339
+ {/* Key Metrics */}
340
+ <div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(3, 1fr)' }}>
341
+ {[
342
+ { label: 'Net Profit', value: `${results.net_profit_pct >= 0 ? '+' : ''}${results.net_profit_pct.toFixed(2)}%`, color: metricColor(results.net_profit_pct) },
343
+ { label: 'Sharpe Ratio', value: results.sharpe_ratio.toFixed(2), color: metricColor(results.sharpe_ratio) },
344
+ { label: 'Max Drawdown', value: `${results.max_drawdown_pct.toFixed(2)}%`, color: '#ef4444' },
345
+ { label: 'Win Rate', value: `${(results.win_rate * 100).toFixed(1)}%`, color: metricColor(results.win_rate - 0.5) },
346
+ { label: 'Profit Factor', value: results.profit_factor === Infinity ? 'N/A' : results.profit_factor.toFixed(2), color: metricColor(results.profit_factor - 1) },
347
+ { label: 'Total Trades', value: results.total_trades, color: 'var(--text-primary)' },
348
+ ].map((m, i) => (
349
+ <div key={i} className="card" style={{ padding: '0.6rem 0.75rem', textAlign: 'center' }}>
350
+ <div style={{ fontSize: '0.6rem', textTransform: 'uppercase', color: 'var(--text-muted)', fontWeight: 600, letterSpacing: '0.03em' }}>{m.label}</div>
351
+ <div style={{ fontSize: '1.15rem', fontWeight: 700, color: m.color }}>{m.value}</div>
352
+ </div>
353
+ ))}
354
+ </div>
355
+
356
+ {/* Secondary Metrics */}
357
+ <div className="card" style={{ padding: '0.75rem 1rem' }}>
358
+ <div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', fontSize: '0.75rem' }}>
359
+ <div><span style={{ color: 'var(--text-muted)' }}>Sortino: </span><strong>{results.sortino_ratio.toFixed(2)}</strong></div>
360
+ <div><span style={{ color: 'var(--text-muted)' }}>Ann. Return: </span><strong style={{ color: metricColor(results.annualized_return_pct) }}>{results.annualized_return_pct.toFixed(2)}%</strong></div>
361
+ <div><span style={{ color: 'var(--text-muted)' }}>Avg Win: </span><strong style={{ color: 'var(--accent-green)' }}>{results.avg_win_pct.toFixed(2)}%</strong></div>
362
+ <div><span style={{ color: 'var(--text-muted)' }}>Avg Loss: </span><strong style={{ color: '#ef4444' }}>{results.avg_loss_pct.toFixed(2)}%</strong></div>
363
+ <div><span style={{ color: 'var(--text-muted)' }}>Final Equity: </span><strong>{formatCurrency(results.final_equity, detectCurrencyFromTicker(backtestTicker))}</strong></div>
364
+ <div><span style={{ color: 'var(--text-muted)' }}>Trading Days: </span><strong>{results.trading_days}</strong></div>
365
+ </div>
366
+ </div>
367
+
368
+ {/* Equity Curve */}
369
+ <div className="card" style={{ padding: '1rem' }}>
370
+ <div style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
371
+ Equity Curve
372
+ </div>
373
+ {results.equity_curve && results.equity_curve.length > 0 && (
374
+ <svg viewBox={`0 0 ${results.equity_curve.length} 100`} style={{ width: '100%', height: 120 }} preserveAspectRatio="none">
375
+ {(() => {
376
+ const eqData = results.equity_curve;
377
+ const minEq = Math.min(...eqData.map((d: any) => d.equity));
378
+ const maxEq = Math.max(...eqData.map((d: any) => d.equity));
379
+ const range = maxEq - minEq || 1;
380
+ const points = eqData.map((d: any, i: number) =>
381
+ `${i},${100 - ((d.equity - minEq) / range) * 90 - 5}`
382
+ ).join(' ');
383
+ const fillPoints = `0,100 ${points} ${eqData.length - 1},100`;
384
+ const isProfit = eqData[eqData.length - 1].equity >= eqData[0].equity;
385
+ return (
386
+ <>
387
+ <defs>
388
+ <linearGradient id="eqGrad" x1="0" y1="0" x2="0" y2="1">
389
+ <stop offset="0%" stopColor={isProfit ? '#10b981' : '#ef4444'} stopOpacity="0.3" />
390
+ <stop offset="100%" stopColor={isProfit ? '#10b981' : '#ef4444'} stopOpacity="0.02" />
391
+ </linearGradient>
392
+ </defs>
393
+ <polygon points={fillPoints} fill="url(#eqGrad)" />
394
+ <polyline points={points} fill="none" stroke={isProfit ? '#10b981' : '#ef4444'} strokeWidth="1.5" />
395
+ </>
396
+ );
397
+ })()}
398
+ </svg>
399
+ )}
400
+ </div>
401
+
402
+ {/* Trade Log */}
403
+ <div className="card" style={{ padding: 0, overflow: 'hidden', maxHeight: 250, overflowY: 'auto' }}>
404
+ <div style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)', padding: '0.6rem 0.75rem', background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-subtle)' }}>
405
+ Trade Log (Last {results.trades?.length || 0})
406
+ </div>
407
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
408
+ <tbody>
409
+ {(results.trades || []).map((t: any, i: number) => (
410
+ <tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)' }}>
411
+ <td style={{ padding: '0.35rem 0.75rem' }}>
412
+ <span style={{
413
+ fontSize: '0.6rem', fontWeight: 700, padding: '0.1rem 0.35rem', borderRadius: 3,
414
+ background: t.type === 'ENTRY' ? 'rgba(16,185,129,0.15)' : 'rgba(239,68,68,0.15)',
415
+ color: t.type === 'ENTRY' ? '#10b981' : '#ef4444',
416
+ }}>{t.type}</span>
417
+ </td>
418
+ <td style={{ padding: '0.35rem 0.5rem', color: 'var(--text-muted)' }}>{t.date}</td>
419
+ <td style={{ padding: '0.35rem 0.5rem' }}>{formatCurrency(t.price, detectCurrencyFromTicker(backtestTicker))}</td>
420
+ {t.pnl_pct !== undefined && (
421
+ <td style={{ padding: '0.35rem 0.5rem', fontWeight: 600, color: t.pnl_pct >= 0 ? 'var(--accent-green)' : '#ef4444' }}>
422
+ {t.pnl_pct >= 0 ? '+' : ''}{t.pnl_pct}%
423
+ </td>
424
+ )}
425
+ </tr>
426
+ ))}
427
+ </tbody>
428
+ </table>
429
+ </div>
430
+ </>
431
+ )}
432
+
433
+ {/* Empty State */}
434
+ {!results && (
435
+ <div style={{ textAlign: 'center', padding: '3rem 2rem', color: 'var(--text-muted)', flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
436
+ <PlayIcon />
437
+ <p style={{ fontSize: '0.85rem', marginTop: '0.5rem' }}>Select a template or generate code, then click Run Backtest</p>
438
+ <p style={{ fontSize: '0.7rem' }}>Results will show equity curve, metrics, and trade log</p>
439
+ </div>
440
+ )}
441
+ </div>
442
+ </div>
443
+ </div>
444
+ );
445
+ }
frontend/src/pages/PortfolioHealth.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useState, useEffect } from 'react';
2
  import { holdingsAPI } from '../api/client';
 
3
 
4
  interface HealthComponent {
5
  name: string;
@@ -86,7 +87,7 @@ export default function PortfolioHealth() {
86
  </div>
87
  <div className="gauge-meta-divider" />
88
  <div className="gauge-meta-item">
89
- <span className="gauge-meta-value">${(data.total_value || 0).toLocaleString()}</span>
90
  <span className="gauge-meta-label">Total Value</span>
91
  </div>
92
  </div>
 
1
  import { useState, useEffect } from 'react';
2
  import { holdingsAPI } from '../api/client';
3
+ import { formatCurrency } from '../utils/currencyUtils';
4
 
5
  interface HealthComponent {
6
  name: string;
 
87
  </div>
88
  <div className="gauge-meta-divider" />
89
  <div className="gauge-meta-item">
90
+ <span className="gauge-meta-value">{formatCurrency(data.total_value || 0)}</span>
91
  <span className="gauge-meta-label">Total Value</span>
92
  </div>
93
  </div>
frontend/src/utils/currencyUtils.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Currency utilities for market-aware monetary display.
3
+ *
4
+ * Maps currency codes to symbols, formatting rules, and market identifiers.
5
+ * Used across the entire QuantHedge frontend to ensure correct currency display.
6
+ */
7
+
8
+ export interface CurrencyInfo {
9
+ symbol: string;
10
+ code: string;
11
+ name: string;
12
+ locale: string;
13
+ market: string; // Market region grouping
14
+ decimalPlaces: number;
15
+ }
16
+
17
+ export const CURRENCIES: Record<string, CurrencyInfo> = {
18
+ USD: { symbol: '$', code: 'USD', name: 'US Dollar', locale: 'en-US', market: 'US', decimalPlaces: 2 },
19
+ INR: { symbol: '₹', code: 'INR', name: 'Indian Rupee', locale: 'en-IN', market: 'India', decimalPlaces: 2 },
20
+ EUR: { symbol: '€', code: 'EUR', name: 'Euro', locale: 'de-DE', market: 'Europe', decimalPlaces: 2 },
21
+ GBP: { symbol: '£', code: 'GBP', name: 'British Pound', locale: 'en-GB', market: 'UK', decimalPlaces: 2 },
22
+ JPY: { symbol: '¥', code: 'JPY', name: 'Japanese Yen', locale: 'ja-JP', market: 'Japan', decimalPlaces: 0 },
23
+ AUD: { symbol: 'A$', code: 'AUD', name: 'Australian Dollar', locale: 'en-AU', market: 'Australia', decimalPlaces: 2 },
24
+ CAD: { symbol: 'C$', code: 'CAD', name: 'Canadian Dollar', locale: 'en-CA', market: 'Canada', decimalPlaces: 2 },
25
+ CHF: { symbol: 'CHF', code: 'CHF', name: 'Swiss Franc', locale: 'de-CH', market: 'Switzerland', decimalPlaces: 2 },
26
+ HKD: { symbol: 'HK$', code: 'HKD', name: 'Hong Kong Dollar', locale: 'zh-HK', market: 'Hong Kong', decimalPlaces: 2 },
27
+ SGD: { symbol: 'S$', code: 'SGD', name: 'Singapore Dollar', locale: 'en-SG', market: 'Singapore', decimalPlaces: 2 },
28
+ CNY: { symbol: '¥', code: 'CNY', name: 'Chinese Yuan', locale: 'zh-CN', market: 'China', decimalPlaces: 2 },
29
+ KRW: { symbol: '₩', code: 'KRW', name: 'Korean Won', locale: 'ko-KR', market: 'South Korea', decimalPlaces: 0 },
30
+ BRL: { symbol: 'R$', code: 'BRL', name: 'Brazilian Real', locale: 'pt-BR', market: 'Brazil', decimalPlaces: 2 },
31
+ RUB: { symbol: '₽', code: 'RUB', name: 'Russian Ruble', locale: 'ru-RU', market: 'Russia', decimalPlaces: 2 },
32
+ ZAR: { symbol: 'R', code: 'ZAR', name: 'South African Rand', locale: 'en-ZA', market: 'South Africa', decimalPlaces: 2 },
33
+ MXN: { symbol: 'Mex$', code: 'MXN', name: 'Mexican Peso', locale: 'es-MX', market: 'Mexico', decimalPlaces: 2 },
34
+ SEK: { symbol: 'kr', code: 'SEK', name: 'Swedish Krona', locale: 'sv-SE', market: 'Sweden', decimalPlaces: 2 },
35
+ NOK: { symbol: 'kr', code: 'NOK', name: 'Norwegian Krone', locale: 'nb-NO', market: 'Norway', decimalPlaces: 2 },
36
+ BTC: { symbol: '₿', code: 'BTC', name: 'Bitcoin', locale: 'en-US', market: 'Crypto', decimalPlaces: 8 },
37
+ ETH: { symbol: 'Ξ', code: 'ETH', name: 'Ethereum', locale: 'en-US', market: 'Crypto', decimalPlaces: 6 },
38
+ };
39
+
40
+ /** Get currency symbol for a code. Falls back to the code itself. */
41
+ export const getCurrencySymbol = (code: string): string =>
42
+ CURRENCIES[code?.toUpperCase()]?.symbol || code || '$';
43
+
44
+ /** Get full currency info. */
45
+ export const getCurrencyInfo = (code: string): CurrencyInfo =>
46
+ CURRENCIES[code?.toUpperCase()] || CURRENCIES.USD;
47
+
48
+ /**
49
+ * Format a monetary value with the correct currency symbol and locale.
50
+ * Example: formatCurrency(1500.50, 'INR') → '₹1,500.50'
51
+ */
52
+ export const formatCurrency = (
53
+ value: number | null | undefined,
54
+ currencyCode: string = 'USD',
55
+ options?: { compact?: boolean; showCode?: boolean }
56
+ ): string => {
57
+ if (value == null || isNaN(value)) return '—';
58
+ const info = getCurrencyInfo(currencyCode);
59
+ const absValue = Math.abs(value);
60
+ const sign = value < 0 ? '-' : '';
61
+
62
+ let formatted: string;
63
+ if (options?.compact && absValue >= 1_000_000) {
64
+ formatted = (absValue / 1_000_000).toFixed(2) + 'M';
65
+ } else if (options?.compact && absValue >= 1_000) {
66
+ formatted = (absValue / 1_000).toFixed(1) + 'K';
67
+ } else {
68
+ formatted = absValue.toLocaleString(info.locale, {
69
+ minimumFractionDigits: Math.min(info.decimalPlaces, 2),
70
+ maximumFractionDigits: Math.min(info.decimalPlaces, 2),
71
+ });
72
+ }
73
+
74
+ const result = `${sign}${info.symbol}${formatted}`;
75
+ return options?.showCode ? `${result} ${info.code}` : result;
76
+ };
77
+
78
+ /**
79
+ * Detect market/currency from ticker suffix.
80
+ * e.g., 'RELIANCE.NS' → 'INR', 'VOD.L' → 'GBP', 'AAPL' → 'USD'
81
+ */
82
+ export const detectCurrencyFromTicker = (ticker: string): string => {
83
+ const t = ticker?.toUpperCase() || '';
84
+ if (t.endsWith('.NS') || t.endsWith('.BO')) return 'INR';
85
+ if (t.endsWith('.L')) return 'GBP';
86
+ if (t.endsWith('.T') || t.endsWith('.TYO')) return 'JPY';
87
+ if (t.endsWith('.DE') || t.endsWith('.PA') || t.endsWith('.AS') || t.endsWith('.MI')) return 'EUR';
88
+ if (t.endsWith('.AX')) return 'AUD';
89
+ if (t.endsWith('.TO') || t.endsWith('.V')) return 'CAD';
90
+ if (t.endsWith('.SW')) return 'CHF';
91
+ if (t.endsWith('.HK')) return 'HKD';
92
+ if (t.endsWith('.SI')) return 'SGD';
93
+ if (t.endsWith('.SS') || t.endsWith('.SZ')) return 'CNY';
94
+ if (t.endsWith('.KS') || t.endsWith('.KQ')) return 'KRW';
95
+ if (t.endsWith('.SA')) return 'BRL';
96
+ if (t.endsWith('.ME')) return 'RUB';
97
+ if (t.endsWith('.JO')) return 'ZAR';
98
+ if (t.endsWith('.MX')) return 'MXN';
99
+ if (t.endsWith('.ST')) return 'SEK';
100
+ if (t.endsWith('.OL')) return 'NOK';
101
+ // Crypto pairs
102
+ if (t.includes('-USD') || t.includes('-USDT')) {
103
+ if (t.startsWith('BTC')) return 'BTC';
104
+ if (t.startsWith('ETH')) return 'ETH';
105
+ }
106
+ return 'USD';
107
+ };
108
+
109
+ /**
110
+ * Get the market name for a currency code.
111
+ */
112
+ export const getMarketName = (currencyCode: string): string =>
113
+ getCurrencyInfo(currencyCode).market;
114
+
115
+ /**
116
+ * Group holdings by their market/currency.
117
+ * Returns a Map of marketName → holdings array.
118
+ */
119
+ export interface HoldingWithCurrency {
120
+ currency: string;
121
+ market_value: number;
122
+ [key: string]: any;
123
+ }
124
+
125
+ export const groupByMarket = <T extends HoldingWithCurrency>(holdings: T[]): Map<string, T[]> => {
126
+ const groups = new Map<string, T[]>();
127
+ for (const h of holdings) {
128
+ const market = getMarketName(h.currency || 'USD');
129
+ if (!groups.has(market)) groups.set(market, []);
130
+ groups.get(market)!.push(h);
131
+ }
132
+ return groups;
133
+ };
134
+
135
+ /**
136
+ * Calculate per-market subtotals from holdings.
137
+ */
138
+ export const getMarketSubtotals = <T extends HoldingWithCurrency>(holdings: T[]) => {
139
+ const groups = groupByMarket(holdings);
140
+ const subtotals: { market: string; currency: string; totalValue: number; count: number }[] = [];
141
+ groups.forEach((items, market) => {
142
+ const currency = items[0]?.currency || 'USD';
143
+ subtotals.push({
144
+ market,
145
+ currency,
146
+ totalValue: items.reduce((sum, h) => sum + (h.market_value || 0), 0),
147
+ count: items.length,
148
+ });
149
+ });
150
+ return subtotals;
151
+ };
152
+
153
+ /** All supported currency codes for dropdowns. */
154
+ export const CURRENCY_OPTIONS = Object.entries(CURRENCIES).map(([code, info]) => ({
155
+ value: code,
156
+ label: `${info.symbol} ${code} — ${info.name}`,
157
+ market: info.market,
158
+ }));