Spaces:
Running
Running
Commit ·
e6021a3
1
Parent(s): 7e60f9e
whole lotta changes
Browse files- backend/app/main.py +3 -1
- backend/app/routers/patterns.py +146 -0
- backend/app/routers/pinescript.py +160 -0
- backend/app/services/ml/pattern_recognition/__init__.py +1 -0
- backend/app/services/ml/pattern_recognition/advanced_features.py +441 -0
- backend/app/services/ml/pattern_recognition/pattern_detector.py +771 -0
- backend/app/services/ml/pattern_recognition/predictor.py +517 -0
- backend/app/services/pinescript/__init__.py +1 -0
- backend/app/services/pinescript/generator.py +855 -0
- backend/app/services/pinescript/pine_backtester.py +386 -0
- backend/app/services/pinescript/validator.py +184 -0
- backend/requirements.txt +1 -0
- frontend/src/App.tsx +8 -0
- frontend/src/api/client.ts +27 -0
- frontend/src/components/Sidebar.tsx +7 -0
- frontend/src/pages/HoldingsTracker.tsx +92 -55
- frontend/src/pages/PatternIntelligence.tsx +434 -0
- frontend/src/pages/PineScriptLab.tsx +445 -0
- frontend/src/pages/PortfolioHealth.tsx +2 -1
- frontend/src/utils/currencyUtils.ts +158 -0
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 |
-
<
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 })}>
|
| 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 |
-
{
|
| 244 |
-
<
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
{
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
<
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
| 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) =>
|
| 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 =>
|
| 310 |
<YAxis dataKey="ticker" type="category" tick={{ fontSize: 11, fill: 'var(--chart-axis)', fontWeight: 500 }} width={60} />
|
| 311 |
-
<Tooltip formatter={(v: any) =>
|
| 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={
|
| 383 |
-
<MetricCard icon={DollarSign} label="After" value={
|
| 384 |
-
<MetricCard icon={TrendingDown} label="Impact" value={
|
| 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' }}>
|
| 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 ? '+' : '
|
| 397 |
-
<td style={{ fontFamily:'var(--font-mono)', fontSize:'0.8rem' }}>
|
| 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={
|
| 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)' }}>
|
| 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} (
|
| 495 |
-
<span style={{ fontWeight:600 }}>
|
| 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} (
|
| 504 |
-
<span style={{ fontWeight:600, color:'var(--green-positive)' }}>+
|
| 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)' }}>
|
| 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={
|
| 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'}}>
|
| 555 |
-
<td style={{fontFamily:'var(--font-mono)',fontSize:'0.8rem'}}>
|
| 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 ? '+' : ''}
|
| 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)' }}>
|
| 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... 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">
|
| 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 |
+
}));
|