Spaces:
Runtime error
Runtime error
Commit Β·
395de87
1
Parent(s): 5ee8052
Security and architecture upgrade
Browse files- .gitignore +0 -0
- access_manager.py +38 -1
- add_mobile_nav.py +0 -76
- alternative_data.py +0 -77
- api.py +0 -371
- app.py +136 -69
- audit_reproducibility.py +0 -174
- backtest.py +13 -5
- config.py +1 -1
- constants.py +1 -1
- core_engine.py +19 -6
- cvxpy_engine.py +4 -1
- data_repository.py +50 -2
- deploy_files.py +0 -17
- deploy_to_hf.py +0 -47
- docker-compose.yml +1 -1
- erc_engine.py +4 -0
- find_nav.py +0 -24
- fix_app.py +0 -42
- fix_app_py.py +0 -17
- fix_cookie.py +0 -15
- fix_encoding.py +0 -7
- fix_headers.py +0 -22
- fix_math.py +59 -0
- forecast_generation.py +139 -125
- models.py +83 -78
- report.py +4 -0
- report_builders/html_diagnostics.py +11 -3
- report_builders/html_risk.py +39 -2
- report_builders/html_validation.py +69 -4
- report_data.py +5 -2
- report_html.py +14 -2
- report_template.html +2 -2
- solver.py +147 -23
- static/admin.js +16 -3
- static/app.js +0 -0
- static/app_clean.js +1194 -0
- static/index.html +0 -0
- static/style.css +110 -4
- test_hf_poll.py +0 -8
- test_hf_wizard.py +0 -18
- test_hf_wizard_auth.py +0 -19
- test_wizard2.py +0 -23
- tests/test_perf.py +8 -7
- update_admin.py +96 -0
- update_app.py +113 -0
- utils/metrics.py +2 -1
- validation.py +120 -0
.gitignore
CHANGED
|
Binary files a/.gitignore and b/.gitignore differ
|
|
|
access_manager.py
CHANGED
|
@@ -34,10 +34,13 @@ def get_geo_location(ip: str) -> str:
|
|
| 34 |
pass
|
| 35 |
return ""
|
| 36 |
|
|
|
|
|
|
|
| 37 |
# Set up access logger
|
| 38 |
access_logger = logging.getLogger("wealth_access")
|
| 39 |
access_logger.setLevel(logging.INFO)
|
| 40 |
-
|
|
|
|
| 41 |
formatter = logging.Formatter('%(asctime)s - %(message)s')
|
| 42 |
handler.setFormatter(formatter)
|
| 43 |
access_logger.addHandler(handler)
|
|
@@ -180,10 +183,43 @@ def validate_key(key: str, ip: str = "Unknown", silent: bool = False) -> bool:
|
|
| 180 |
|
| 181 |
return handle_failure()
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def generate_otk(admin_key: str, new_key: str = None, hours: int = 1) -> str:
|
| 184 |
if admin_key != MASTER_KEY:
|
| 185 |
return None
|
| 186 |
|
|
|
|
|
|
|
| 187 |
if not new_key:
|
| 188 |
new_key = "OTK-" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
| 189 |
|
|
@@ -212,4 +248,5 @@ def revoke_otk(admin_key: str, target_key: str) -> bool:
|
|
| 212 |
def get_all_keys(admin_key: str) -> dict:
|
| 213 |
if admin_key != MASTER_KEY:
|
| 214 |
return {}
|
|
|
|
| 215 |
return load_keys().get("otk", {})
|
|
|
|
| 34 |
pass
|
| 35 |
return ""
|
| 36 |
|
| 37 |
+
import logging.handlers
|
| 38 |
+
|
| 39 |
# Set up access logger
|
| 40 |
access_logger = logging.getLogger("wealth_access")
|
| 41 |
access_logger.setLevel(logging.INFO)
|
| 42 |
+
# Rotate every 30 days, keep 1 backup
|
| 43 |
+
handler = logging.handlers.TimedRotatingFileHandler(os.path.join(OUTPUT_DIR, "access.log"), when='D', interval=30, backupCount=1, encoding='utf-8')
|
| 44 |
formatter = logging.Formatter('%(asctime)s - %(message)s')
|
| 45 |
handler.setFormatter(formatter)
|
| 46 |
access_logger.addHandler(handler)
|
|
|
|
| 183 |
|
| 184 |
return handle_failure()
|
| 185 |
|
| 186 |
+
def cleanup_expired_keys():
|
| 187 |
+
try:
|
| 188 |
+
data = load_keys()
|
| 189 |
+
if "otk" not in data:
|
| 190 |
+
return
|
| 191 |
+
|
| 192 |
+
now = datetime.now(timezone.utc)
|
| 193 |
+
cutoff = now - timedelta(days=30)
|
| 194 |
+
|
| 195 |
+
keys_to_delete = []
|
| 196 |
+
for key, info in data["otk"].items():
|
| 197 |
+
expires_str = info.get("expires_at")
|
| 198 |
+
if expires_str:
|
| 199 |
+
expires_dt = to_aware(datetime.fromisoformat(expires_str))
|
| 200 |
+
if expires_dt < cutoff:
|
| 201 |
+
keys_to_delete.append(key)
|
| 202 |
+
elif info.get("revoked", False):
|
| 203 |
+
created_str = info.get("created_at")
|
| 204 |
+
if created_str:
|
| 205 |
+
created_dt = to_aware(datetime.fromisoformat(created_str))
|
| 206 |
+
if created_dt < cutoff:
|
| 207 |
+
keys_to_delete.append(key)
|
| 208 |
+
|
| 209 |
+
if keys_to_delete:
|
| 210 |
+
for k in keys_to_delete:
|
| 211 |
+
del data["otk"][k]
|
| 212 |
+
save_keys(data)
|
| 213 |
+
access_logger.info(f"Cleaned up {len(keys_to_delete)} old/expired keys.")
|
| 214 |
+
except Exception as e:
|
| 215 |
+
access_logger.error(f"Failed to cleanup keys: {e}")
|
| 216 |
+
|
| 217 |
def generate_otk(admin_key: str, new_key: str = None, hours: int = 1) -> str:
|
| 218 |
if admin_key != MASTER_KEY:
|
| 219 |
return None
|
| 220 |
|
| 221 |
+
cleanup_expired_keys()
|
| 222 |
+
|
| 223 |
if not new_key:
|
| 224 |
new_key = "OTK-" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
| 225 |
|
|
|
|
| 248 |
def get_all_keys(admin_key: str) -> dict:
|
| 249 |
if admin_key != MASTER_KEY:
|
| 250 |
return {}
|
| 251 |
+
cleanup_expired_keys()
|
| 252 |
return load_keys().get("otk", {})
|
add_mobile_nav.py
DELETED
|
@@ -1,76 +0,0 @@
|
|
| 1 |
-
content = open('static/index.html', 'rb').read().decode('utf-8')
|
| 2 |
-
|
| 3 |
-
# 1. Add hamburger button + overlay BEFORE nav-links div, and id to nav-links
|
| 4 |
-
old_nav = '<div class="nav-links">'
|
| 5 |
-
new_nav = '''<button class="hamburger-btn" id="hamburgerBtn" aria-label="Open menu" onclick="toggleMobileNav()">
|
| 6 |
-
<span></span><span></span><span></span>
|
| 7 |
-
</button>
|
| 8 |
-
<div class="nav-links" id="navLinks">'''
|
| 9 |
-
|
| 10 |
-
# Also add a close button at the top of nav-links for mobile
|
| 11 |
-
# We'll add it after the nav-links opening
|
| 12 |
-
|
| 13 |
-
content = content.replace(old_nav, new_nav, 1)
|
| 14 |
-
|
| 15 |
-
# 2. After the nav-links closing div, add the overlay
|
| 16 |
-
# Find the closing pattern of nav-links
|
| 17 |
-
old_close = '</div>\n </div>\n </nav>'
|
| 18 |
-
new_close = '''</div>
|
| 19 |
-
</div>
|
| 20 |
-
</nav>
|
| 21 |
-
<div class="mobile-nav-overlay" id="mobileNavOverlay" onclick="toggleMobileNav()"></div>'''
|
| 22 |
-
|
| 23 |
-
# Try both CRLF and LF variants
|
| 24 |
-
replaced = False
|
| 25 |
-
for variant in [
|
| 26 |
-
'</div>\r\n </div>\r\n </nav>',
|
| 27 |
-
'</div>\n </div>\n </nav>',
|
| 28 |
-
'</div>\r\r\n </div>\r\r\n </nav>',
|
| 29 |
-
]:
|
| 30 |
-
if variant in content:
|
| 31 |
-
content = content.replace(variant, new_close.replace('\n', '\r\n'), 1)
|
| 32 |
-
print('Replaced nav close')
|
| 33 |
-
replaced = True
|
| 34 |
-
break
|
| 35 |
-
|
| 36 |
-
if not replaced:
|
| 37 |
-
print('Nav close not found - checking...')
|
| 38 |
-
idx = content.find('</nav>')
|
| 39 |
-
print(repr(content[max(0,idx-100):idx+10]))
|
| 40 |
-
|
| 41 |
-
# 3. Add mobile nav JS at end of body scripts
|
| 42 |
-
mobile_js = """
|
| 43 |
-
// ββ Mobile Navigation βββββββββββββββββββββββββββββββββββββ
|
| 44 |
-
function toggleMobileNav() {
|
| 45 |
-
const btn = document.getElementById('hamburgerBtn');
|
| 46 |
-
const links = document.getElementById('navLinks');
|
| 47 |
-
const overlay = document.getElementById('mobileNavOverlay');
|
| 48 |
-
const isOpen = links.classList.contains('mobile-open');
|
| 49 |
-
if (isOpen) {
|
| 50 |
-
btn.classList.remove('open');
|
| 51 |
-
links.classList.remove('mobile-open');
|
| 52 |
-
overlay.classList.remove('active');
|
| 53 |
-
document.body.style.overflow = '';
|
| 54 |
-
} else {
|
| 55 |
-
btn.classList.add('open');
|
| 56 |
-
links.classList.add('mobile-open');
|
| 57 |
-
overlay.classList.add('active');
|
| 58 |
-
document.body.style.overflow = 'hidden';
|
| 59 |
-
}
|
| 60 |
-
}
|
| 61 |
-
// Close mobile nav on link click
|
| 62 |
-
document.querySelectorAll('.nav-link').forEach(el => {
|
| 63 |
-
el.addEventListener('click', () => {
|
| 64 |
-
const links = document.getElementById('navLinks');
|
| 65 |
-
if (links && links.classList.contains('mobile-open')) {
|
| 66 |
-
toggleMobileNav();
|
| 67 |
-
}
|
| 68 |
-
});
|
| 69 |
-
});
|
| 70 |
-
"""
|
| 71 |
-
|
| 72 |
-
# Insert before </body>
|
| 73 |
-
content = content.replace('</body>', f'<script>{mobile_js}</script>\n</body>', 1)
|
| 74 |
-
|
| 75 |
-
open('static/index.html', 'w', encoding='utf-8').write(content)
|
| 76 |
-
print('Done! Mobile nav added to index.html')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
alternative_data.py
CHANGED
|
@@ -5,83 +5,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
| 5 |
from config import logger, Color
|
| 6 |
import warnings
|
| 7 |
|
| 8 |
-
def _fetch_single_option_sentiment(ticker: str) -> dict:
|
| 9 |
-
"""
|
| 10 |
-
Fetches the near-term options chain for a single ticker to calculate
|
| 11 |
-
put/call ratio and an implied volatility skew proxy.
|
| 12 |
-
"""
|
| 13 |
-
result = {'put_call_ratio': 1.0, 'iv_skew': 0.0}
|
| 14 |
-
|
| 15 |
-
try:
|
| 16 |
-
tk = yf.Ticker(ticker)
|
| 17 |
-
expirations = tk.options
|
| 18 |
-
|
| 19 |
-
if not expirations:
|
| 20 |
-
return result
|
| 21 |
-
|
| 22 |
-
# Get the nearest expiration to capture current speculative sentiment
|
| 23 |
-
opt = tk.option_chain(expirations[0])
|
| 24 |
-
calls = opt.calls
|
| 25 |
-
puts = opt.puts
|
| 26 |
-
|
| 27 |
-
# Calculate Volume-based Put/Call Ratio (fallback to Open Interest if volume is 0/NaN)
|
| 28 |
-
call_vol = calls['volume'].sum() if 'volume' in calls else 0
|
| 29 |
-
put_vol = puts['volume'].sum() if 'volume' in puts else 0
|
| 30 |
-
|
| 31 |
-
if call_vol == 0 and put_vol == 0:
|
| 32 |
-
call_vol = calls['openInterest'].sum() if 'openInterest' in calls else 0
|
| 33 |
-
put_vol = puts['openInterest'].sum() if 'openInterest' in puts else 0
|
| 34 |
-
|
| 35 |
-
if call_vol > 0:
|
| 36 |
-
result['put_call_ratio'] = float(put_vol / call_vol)
|
| 37 |
-
elif put_vol > 0:
|
| 38 |
-
result['put_call_ratio'] = 5.0 # Arbitrary cap for extremely bearish flow
|
| 39 |
-
|
| 40 |
-
# Calculate a simple Implied Volatility Skew proxy (Average Put IV - Average Call IV)
|
| 41 |
-
# In a real system, you would interpolate exact OTM strikes (e.g. 25-delta puts vs 25-delta calls)
|
| 42 |
-
if 'impliedVolatility' in calls and 'impliedVolatility' in puts:
|
| 43 |
-
call_iv = calls['impliedVolatility'].replace(0.0, np.nan).mean()
|
| 44 |
-
put_iv = puts['impliedVolatility'].replace(0.0, np.nan).mean()
|
| 45 |
-
|
| 46 |
-
if pd.notna(call_iv) and pd.notna(put_iv):
|
| 47 |
-
result['iv_skew'] = float(put_iv - call_iv)
|
| 48 |
-
|
| 49 |
-
except Exception as e:
|
| 50 |
-
logger.debug(f"Failed to fetch options sentiment for {ticker}: {e}")
|
| 51 |
-
|
| 52 |
-
return result
|
| 53 |
-
|
| 54 |
-
def fetch_options_sentiment(tickers: list, silent: bool = False) -> dict:
|
| 55 |
-
"""
|
| 56 |
-
Parallelized fetcher for options market sentiment.
|
| 57 |
-
Returns a dictionary mapping tickers to their sentiment features.
|
| 58 |
-
"""
|
| 59 |
-
if not silent:
|
| 60 |
-
print(f" {Color.CYAN}[INFO] Fetching alternative data (options flow) for {len(tickers)} assets...{Color.RESET}", end="", flush=True)
|
| 61 |
-
|
| 62 |
-
results = {}
|
| 63 |
-
|
| 64 |
-
with warnings.catch_warnings():
|
| 65 |
-
warnings.simplefilter("ignore")
|
| 66 |
-
with ThreadPoolExecutor(max_workers=min(10, len(tickers) if tickers else 1)) as executor:
|
| 67 |
-
future_to_ticker = {
|
| 68 |
-
executor.submit(_fetch_single_option_sentiment, t): t for t in tickers
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
for future in as_completed(future_to_ticker):
|
| 72 |
-
t = future_to_ticker[future]
|
| 73 |
-
try:
|
| 74 |
-
sentiment = future.result()
|
| 75 |
-
results[t] = sentiment
|
| 76 |
-
except Exception as e:
|
| 77 |
-
logger.error(f"Error processing options for {t}: {e}")
|
| 78 |
-
results[t] = {'put_call_ratio': 1.0, 'iv_skew': 0.0}
|
| 79 |
-
|
| 80 |
-
if not silent:
|
| 81 |
-
print(f" {Color.GREEN}done.{Color.RESET}")
|
| 82 |
-
|
| 83 |
-
return results
|
| 84 |
-
|
| 85 |
import requests
|
| 86 |
import urllib.parse
|
| 87 |
import xml.etree.ElementTree as ET
|
|
|
|
| 5 |
from config import logger, Color
|
| 6 |
import warnings
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import requests
|
| 9 |
import urllib.parse
|
| 10 |
import xml.etree.ElementTree as ET
|
api.py
DELETED
|
@@ -1,371 +0,0 @@
|
|
| 1 |
-
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Depends, Request
|
| 2 |
-
from fastapi.security import APIKeyHeader
|
| 3 |
-
from pydantic import BaseModel
|
| 4 |
-
from typing import List, Optional, Dict, Any
|
| 5 |
-
import logging
|
| 6 |
-
import threading
|
| 7 |
-
import asyncio
|
| 8 |
-
import numpy as np
|
| 9 |
-
import redis
|
| 10 |
-
import json
|
| 11 |
-
import os
|
| 12 |
-
import hashlib
|
| 13 |
-
from core_engine import run_engine
|
| 14 |
-
|
| 15 |
-
try:
|
| 16 |
-
from dotenv import load_dotenv
|
| 17 |
-
load_dotenv()
|
| 18 |
-
except ImportError:
|
| 19 |
-
pass
|
| 20 |
-
|
| 21 |
-
from opentelemetry import trace
|
| 22 |
-
from opentelemetry.sdk.trace import TracerProvider
|
| 23 |
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
| 24 |
-
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
| 25 |
-
|
| 26 |
-
# Initialize OpenTelemetry Tracer
|
| 27 |
-
provider = TracerProvider()
|
| 28 |
-
processor = BatchSpanProcessor(ConsoleSpanExporter())
|
| 29 |
-
provider.add_span_processor(processor)
|
| 30 |
-
trace.set_tracer_provider(provider)
|
| 31 |
-
tracer = trace.get_tracer(__name__)
|
| 32 |
-
|
| 33 |
-
app = FastAPI(title="Portfolio Engine API", version="1.0.0")
|
| 34 |
-
|
| 35 |
-
# Instrument FastAPI for automatic endpoint tracing
|
| 36 |
-
FastAPIInstrumentor.instrument_app(app)
|
| 37 |
-
|
| 38 |
-
API_KEY = os.getenv("API_KEY")
|
| 39 |
-
if API_KEY is None:
|
| 40 |
-
raise RuntimeError(
|
| 41 |
-
"FATAL: API_KEY environment variable must be set. "
|
| 42 |
-
"Refusing to start with default credentials."
|
| 43 |
-
)
|
| 44 |
-
api_key_header = APIKeyHeader(name="X-API-Key")
|
| 45 |
-
|
| 46 |
-
def verify_api_key(api_key: str = Depends(api_key_header)):
|
| 47 |
-
if api_key != API_KEY:
|
| 48 |
-
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
| 49 |
-
return api_key
|
| 50 |
-
|
| 51 |
-
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"), decode_responses=True)
|
| 52 |
-
|
| 53 |
-
def rate_limit(request: Request, limit: int = 10, window: int = 60):
|
| 54 |
-
ip = request.client.host if request.client else "127.0.0.1"
|
| 55 |
-
key = f"rate_limit:{ip}:{request.url.path}"
|
| 56 |
-
try:
|
| 57 |
-
current = redis_client.get(key)
|
| 58 |
-
if current and int(current) >= limit:
|
| 59 |
-
raise HTTPException(status_code=429, detail="Too Many Requests")
|
| 60 |
-
pipe = redis_client.pipeline()
|
| 61 |
-
pipe.incr(key)
|
| 62 |
-
pipe.expire(key, window)
|
| 63 |
-
pipe.execute()
|
| 64 |
-
except redis.RedisError as e:
|
| 65 |
-
logging.warning(f"Redis rate limiter failed, bypassing: {e}")
|
| 66 |
-
|
| 67 |
-
# Global state to hold the latest portfolio for the WebSocket dashboard
|
| 68 |
-
GLOBAL_STATE = {
|
| 69 |
-
"capital": 0.0,
|
| 70 |
-
"weights": {},
|
| 71 |
-
"prices": {},
|
| 72 |
-
"shares": {},
|
| 73 |
-
"pnl": 0.0
|
| 74 |
-
}
|
| 75 |
-
import asyncio
|
| 76 |
-
GLOBAL_STATE_LOCK = asyncio.Lock()
|
| 77 |
-
|
| 78 |
-
from pydantic import BaseModel, Field
|
| 79 |
-
|
| 80 |
-
class PortfolioRequest(BaseModel):
|
| 81 |
-
tickers: List[str] = Field(["SPY", "TLT", "GLD"], min_length=1, description="List of asset tickers")
|
| 82 |
-
capital: float = Field(100000.0, gt=0, description="Total capital to allocate")
|
| 83 |
-
risk: int = Field(5, ge=1, le=10, description="Risk tolerance level (1-10)")
|
| 84 |
-
model: int = Field(6, ge=1, le=7, description="1=CAPM, 2=BL, 3=Bayes, 4=FF, 5=ML, 6=E2E, 7=World Model")
|
| 85 |
-
engine: int = Field(1, ge=1, le=2, description="Allocation engine (1=Convex, 2=HRP)")
|
| 86 |
-
currency: str = Field("$", max_length=5)
|
| 87 |
-
days: int = Field(252, ge=1, le=365)
|
| 88 |
-
bsts: bool = False
|
| 89 |
-
monthly: bool = False
|
| 90 |
-
tax: bool = False
|
| 91 |
-
excel: bool = False
|
| 92 |
-
no_dynamic_risk: bool = False
|
| 93 |
-
with_futures: bool = False
|
| 94 |
-
overlay_mode: str = Field("beta_hedge", description="Futures overlay mode")
|
| 95 |
-
futures_target_beta: float = Field(0.0, ge=-2.0, le=2.0)
|
| 96 |
-
futures_universe: List[str] = ["MES", "ES"]
|
| 97 |
-
futures_safety_multiplier: float = Field(3.0, ge=1.0, le=10.0)
|
| 98 |
-
futures_margin_headroom: float = Field(0.05, ge=0.0, le=0.5)
|
| 99 |
-
current_weights: Dict[str, float] = {}
|
| 100 |
-
|
| 101 |
-
class OptimizationResponse(BaseModel):
|
| 102 |
-
status: str
|
| 103 |
-
message: str
|
| 104 |
-
|
| 105 |
-
def get_risk_factor(risk_level: int) -> float:
|
| 106 |
-
risk_map = {
|
| 107 |
-
1: 0.1, 2: 0.5, 3: 1.0, 4: 2.0, 5: 3.0,
|
| 108 |
-
6: 5.0, 7: 7.5, 8: 10.0, 9: 15.0, 10: 25.0
|
| 109 |
-
}
|
| 110 |
-
return risk_map.get(risk_level, 3.0)
|
| 111 |
-
|
| 112 |
-
@app.post("/run_optimization",
|
| 113 |
-
response_model=OptimizationResponse,
|
| 114 |
-
summary="Run full portfolio optimization")
|
| 115 |
-
async def run_optimization(req: PortfolioRequest, request: Request, api_key: str = Depends(verify_api_key)):
|
| 116 |
-
"""Triggers the heavy optimization pipeline natively in Python via cvxpy/ML stack."""
|
| 117 |
-
rate_limit(request, limit=5, window=60)
|
| 118 |
-
try:
|
| 119 |
-
req_hash = hashlib.sha256(json.dumps(req.model_dump(), sort_keys=True).encode()).hexdigest()
|
| 120 |
-
cache_key = f"opt_{req_hash}"
|
| 121 |
-
try:
|
| 122 |
-
cached_state_json = redis_client.get(cache_key)
|
| 123 |
-
if cached_state_json:
|
| 124 |
-
logging.info("Returning cached optimization result")
|
| 125 |
-
cached_state = json.loads(cached_state_json)
|
| 126 |
-
async with GLOBAL_STATE_LOCK:
|
| 127 |
-
GLOBAL_STATE.update(cached_state)
|
| 128 |
-
return {"status": "success", "message": "Optimization completed successfully (cached)."}
|
| 129 |
-
except redis.RedisError as e:
|
| 130 |
-
logging.warning(f"Redis cache check failed: {e}")
|
| 131 |
-
|
| 132 |
-
overrides = {
|
| 133 |
-
"tickers": req.tickers,
|
| 134 |
-
"capital": req.capital,
|
| 135 |
-
"risk_input": req.risk,
|
| 136 |
-
"risk_factor": get_risk_factor(req.risk),
|
| 137 |
-
"model": req.model,
|
| 138 |
-
"allocation_engine": req.engine,
|
| 139 |
-
"current_weights_raw": req.current_weights,
|
| 140 |
-
"headless": True,
|
| 141 |
-
"cfg_overrides": {
|
| 142 |
-
"currency_symbol": req.currency,
|
| 143 |
-
"trading_days_per_year": req.days,
|
| 144 |
-
"bsts_enabled": req.bsts,
|
| 145 |
-
"tax_enabled": req.tax,
|
| 146 |
-
"dynamic_risk": not req.no_dynamic_risk,
|
| 147 |
-
"export_excel": req.excel,
|
| 148 |
-
"with_futures": req.with_futures,
|
| 149 |
-
"overlay_mode": req.overlay_mode,
|
| 150 |
-
"futures_universe": req.futures_universe,
|
| 151 |
-
"futures_target_beta": req.futures_target_beta,
|
| 152 |
-
"futures_safety_multiplier": req.futures_safety_multiplier,
|
| 153 |
-
"futures_margin_headroom": req.futures_margin_headroom,
|
| 154 |
-
}
|
| 155 |
-
}
|
| 156 |
-
|
| 157 |
-
if req.monthly:
|
| 158 |
-
overrides["cfg_overrides"]["return_frequency"] = "monthly"
|
| 159 |
-
|
| 160 |
-
import functools
|
| 161 |
-
loop = asyncio.get_event_loop()
|
| 162 |
-
|
| 163 |
-
with tracer.start_as_current_span("run_engine_pipeline_async_task"):
|
| 164 |
-
task = loop.run_in_executor(None, functools.partial(run_engine, overrides=overrides))
|
| 165 |
-
try:
|
| 166 |
-
opt_res = await task
|
| 167 |
-
except asyncio.CancelledError:
|
| 168 |
-
logging.info("Optimization task cancelled by client.")
|
| 169 |
-
raise
|
| 170 |
-
|
| 171 |
-
# Populate global state for live streaming
|
| 172 |
-
weights = opt_res.get("target_weights", {})
|
| 173 |
-
prices = opt_res.get("prices", {})
|
| 174 |
-
capital = req.capital
|
| 175 |
-
|
| 176 |
-
shares = {}
|
| 177 |
-
for t, w in weights.items():
|
| 178 |
-
if t == 'CASH' or t not in prices:
|
| 179 |
-
continue
|
| 180 |
-
shares[t] = (capital * w) / prices[t]
|
| 181 |
-
|
| 182 |
-
state_update = {
|
| 183 |
-
"capital": capital,
|
| 184 |
-
"weights": weights,
|
| 185 |
-
"prices": prices.copy(),
|
| 186 |
-
"shares": shares,
|
| 187 |
-
"pnl": 0.0
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
async with GLOBAL_STATE_LOCK:
|
| 191 |
-
GLOBAL_STATE.update(state_update)
|
| 192 |
-
|
| 193 |
-
try:
|
| 194 |
-
redis_client.setex(cache_key, 3600, json.dumps(state_update))
|
| 195 |
-
except redis.RedisError as e:
|
| 196 |
-
logging.warning(f"Failed to cache result in Redis: {e}")
|
| 197 |
-
|
| 198 |
-
# Write to Audit Log
|
| 199 |
-
try:
|
| 200 |
-
from database import get_pg_engine, AuditLog
|
| 201 |
-
from sqlalchemy.orm import sessionmaker
|
| 202 |
-
engine = get_pg_engine()
|
| 203 |
-
Session = sessionmaker(bind=engine)
|
| 204 |
-
with Session() as session:
|
| 205 |
-
log_entry = AuditLog(
|
| 206 |
-
user_id=api_key,
|
| 207 |
-
endpoint=request.url.path,
|
| 208 |
-
request_hash=req_hash,
|
| 209 |
-
request_body=req.model_dump(),
|
| 210 |
-
response_weights=weights,
|
| 211 |
-
ip_address=request.client.host if request.client else "unknown"
|
| 212 |
-
)
|
| 213 |
-
session.add(log_entry)
|
| 214 |
-
session.commit()
|
| 215 |
-
except Exception as e:
|
| 216 |
-
logging.error(f"Failed to write audit log: {e}")
|
| 217 |
-
|
| 218 |
-
return {"status": "success", "message": "Optimization completed successfully."}
|
| 219 |
-
|
| 220 |
-
except Exception as e:
|
| 221 |
-
import traceback
|
| 222 |
-
traceback.print_exc()
|
| 223 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 224 |
-
|
| 225 |
-
@app.websocket("/ws")
|
| 226 |
-
async def websocket_endpoint(websocket: WebSocket):
|
| 227 |
-
api_key = websocket.headers.get("X-API-Key") or websocket.query_params.get("api_key")
|
| 228 |
-
if api_key != API_KEY:
|
| 229 |
-
await websocket.close(code=1008)
|
| 230 |
-
return
|
| 231 |
-
|
| 232 |
-
await websocket.accept()
|
| 233 |
-
rng = np.random.default_rng()
|
| 234 |
-
try:
|
| 235 |
-
while True:
|
| 236 |
-
if not GLOBAL_STATE["shares"]:
|
| 237 |
-
await asyncio.sleep(1)
|
| 238 |
-
continue
|
| 239 |
-
|
| 240 |
-
async with GLOBAL_STATE_LOCK:
|
| 241 |
-
tickers_list = list(GLOBAL_STATE["shares"].keys())
|
| 242 |
-
|
| 243 |
-
if tickers_list:
|
| 244 |
-
try:
|
| 245 |
-
# Fetch real live data
|
| 246 |
-
import yfinance as yf
|
| 247 |
-
tickers_str = " ".join(tickers_list)
|
| 248 |
-
data = yf.download(tickers_str, period="1d", interval="1m", progress=False)
|
| 249 |
-
|
| 250 |
-
if not data.empty and 'Close' in data:
|
| 251 |
-
close_data = data['Close']
|
| 252 |
-
|
| 253 |
-
current_value = 0.0
|
| 254 |
-
new_prices = {}
|
| 255 |
-
|
| 256 |
-
async with GLOBAL_STATE_LOCK:
|
| 257 |
-
for t, share_qty in GLOBAL_STATE["shares"].items():
|
| 258 |
-
try:
|
| 259 |
-
# Handle MultiIndex for multiple tickers vs SingleIndex for one ticker
|
| 260 |
-
if len(tickers_list) > 1:
|
| 261 |
-
if t in close_data.columns:
|
| 262 |
-
price = float(close_data[t].iloc[-1])
|
| 263 |
-
else:
|
| 264 |
-
price = GLOBAL_STATE["prices"].get(t, 100.0)
|
| 265 |
-
else:
|
| 266 |
-
price = float(close_data.iloc[-1])
|
| 267 |
-
|
| 268 |
-
if not pd.isna(price):
|
| 269 |
-
GLOBAL_STATE["prices"][t] = price
|
| 270 |
-
new_prices[t] = round(price, 2)
|
| 271 |
-
current_value += share_qty * price
|
| 272 |
-
except Exception as e:
|
| 273 |
-
logging.error(f"Error extracting price for {t}: {e}")
|
| 274 |
-
|
| 275 |
-
cash = GLOBAL_STATE["capital"] * GLOBAL_STATE["weights"].get("CASH", 0.0)
|
| 276 |
-
current_value += cash
|
| 277 |
-
GLOBAL_STATE["pnl"] = current_value - GLOBAL_STATE["capital"]
|
| 278 |
-
|
| 279 |
-
payload = {
|
| 280 |
-
"type": "live_update",
|
| 281 |
-
"capital": round(current_value, 2),
|
| 282 |
-
"pnl": round(GLOBAL_STATE["pnl"], 2),
|
| 283 |
-
"prices": new_prices
|
| 284 |
-
}
|
| 285 |
-
await websocket.send_json(payload)
|
| 286 |
-
except Exception as e:
|
| 287 |
-
logging.error(f"Error fetching live data: {e}")
|
| 288 |
-
|
| 289 |
-
await asyncio.sleep(10)
|
| 290 |
-
|
| 291 |
-
except WebSocketDisconnect:
|
| 292 |
-
logging.info("WebSocket disconnected")
|
| 293 |
-
|
| 294 |
-
@app.get("/health")
|
| 295 |
-
def health_check():
|
| 296 |
-
return {"status": "healthy"}
|
| 297 |
-
|
| 298 |
-
@app.get("/api/ping")
|
| 299 |
-
async def ping():
|
| 300 |
-
"""Endpoint for UptimeRobot to ping Render, which in turn pings HF to keep both awake."""
|
| 301 |
-
hf_url = os.getenv("HF_BACKEND_URL", "https://engineportf-portfolio-opt.hf.space").rstrip('/')
|
| 302 |
-
import requests
|
| 303 |
-
try:
|
| 304 |
-
requests.get(f"{hf_url}/", timeout=10)
|
| 305 |
-
except:
|
| 306 |
-
pass
|
| 307 |
-
return {"status": "awake"}
|
| 308 |
-
|
| 309 |
-
class ChatRequest(BaseModel):
|
| 310 |
-
message: str
|
| 311 |
-
portfolio_context: dict
|
| 312 |
-
|
| 313 |
-
@app.post("/api/chat")
|
| 314 |
-
async def chat_with_portfolio(req: ChatRequest):
|
| 315 |
-
try:
|
| 316 |
-
from huggingface_hub import InferenceClient
|
| 317 |
-
has_hf_hub = True
|
| 318 |
-
except ImportError:
|
| 319 |
-
has_hf_hub = False
|
| 320 |
-
|
| 321 |
-
if not has_hf_hub:
|
| 322 |
-
raise HTTPException(status_code=500, detail="huggingface_hub is not installed on the server.")
|
| 323 |
-
|
| 324 |
-
try:
|
| 325 |
-
hf_token = os.environ.get("HF_TOKEN", "")
|
| 326 |
-
if not hf_token:
|
| 327 |
-
return {"status": "error", "detail": "AI is disabled. Please add 'HF_TOKEN' to your Hugging Face Space Secrets to enable the AI."}
|
| 328 |
-
|
| 329 |
-
system_prompt = (
|
| 330 |
-
"You are an elite quantitative analyst AI. "
|
| 331 |
-
"You are explaining the user's mathematical portfolio allocation. "
|
| 332 |
-
"Never give explicit financial advice (e.g. 'You must buy this stock'). "
|
| 333 |
-
"Only explain WHY the math chose these weights based on the user's inputs and market metrics. "
|
| 334 |
-
f"Here is the user's current mathematically optimized portfolio context: {req.portfolio_context}"
|
| 335 |
-
)
|
| 336 |
-
|
| 337 |
-
prompt = f"<s>[INST] {system_prompt}\n\nContext:\n{req.portfolio_context}\n\nUser: {req.message} [/INST]"
|
| 338 |
-
|
| 339 |
-
try:
|
| 340 |
-
from huggingface_hub import InferenceClient
|
| 341 |
-
client = InferenceClient(model="mistralai/Mistral-7B-Instruct-v0.3", token=hf_token)
|
| 342 |
-
response = client.text_generation(prompt, max_new_tokens=500, temperature=0.3, return_full_text=False)
|
| 343 |
-
return {"status": "success", "response": response.strip()}
|
| 344 |
-
except Exception as client_err:
|
| 345 |
-
logging.warning(f"InferenceClient failed: {client_err}. Falling back to requests.")
|
| 346 |
-
import requests
|
| 347 |
-
api_url = "https://api-inference.huggingface.co/models/mistralai/Mistral-7B-Instruct-v0.3"
|
| 348 |
-
headers = {"Authorization": f"Bearer {hf_token}"}
|
| 349 |
-
payload = {
|
| 350 |
-
"inputs": prompt,
|
| 351 |
-
"parameters": {"max_new_tokens": 500, "temperature": 0.3, "return_full_text": False}
|
| 352 |
-
}
|
| 353 |
-
try:
|
| 354 |
-
res = requests.post(api_url, headers=headers, json=payload, timeout=60)
|
| 355 |
-
if res.ok:
|
| 356 |
-
data = res.json()
|
| 357 |
-
if isinstance(data, list) and len(data) > 0:
|
| 358 |
-
response_text = data[0].get("generated_text", "AI response empty.")
|
| 359 |
-
return {"status": "success", "response": response_text.strip()}
|
| 360 |
-
elif isinstance(data, dict) and "error" in data:
|
| 361 |
-
return {"status": "error", "detail": f"Hugging Face AI Error: {data['error']}"}
|
| 362 |
-
else:
|
| 363 |
-
return {"status": "success", "response": str(data)}
|
| 364 |
-
else:
|
| 365 |
-
return {"status": "error", "detail": f"Hugging Face API Error: {res.status_code} - {res.text}"}
|
| 366 |
-
except Exception as req_err:
|
| 367 |
-
return {"status": "error", "detail": f"AI temporarily unavailable due to server networking issues (DNS): {req_err}"}
|
| 368 |
-
|
| 369 |
-
except Exception as e:
|
| 370 |
-
logging.error(f"AI Chat error: {e}")
|
| 371 |
-
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -34,7 +34,6 @@ try:
|
|
| 34 |
except ImportError:
|
| 35 |
pass
|
| 36 |
|
| 37 |
-
BACKGROUND_TASKS = {}
|
| 38 |
|
| 39 |
try:
|
| 40 |
from diagnostics import TraceManager
|
|
@@ -56,6 +55,44 @@ except ImportError:
|
|
| 56 |
from config import OUTPUT_DIR, logger
|
| 57 |
import access_manager
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 60 |
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
| 61 |
|
|
@@ -146,6 +183,7 @@ class PortfolioRequest(BaseModel):
|
|
| 146 |
allow_shorting: bool = True
|
| 147 |
tax_enabled: bool = False
|
| 148 |
garch_enabled: bool = True
|
|
|
|
| 149 |
custom_constraints: Optional[List[dict]] = None
|
| 150 |
|
| 151 |
class ChatHistoryItem(BaseModel):
|
|
@@ -337,25 +375,26 @@ NEWS_CACHE_TTL = 300
|
|
| 337 |
@app.get("/api/finance_news")
|
| 338 |
async def finance_news():
|
| 339 |
global news_cache
|
| 340 |
-
if time.time() - news_cache
|
| 341 |
return news_cache["data"]
|
| 342 |
|
| 343 |
results = []
|
| 344 |
try:
|
| 345 |
-
import
|
| 346 |
import xml.etree.ElementTree as ET
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
| 352 |
for item in root.findall('./channel/item')[:15]:
|
| 353 |
-
title = item.find('title').text
|
| 354 |
pub_date = item.find('pubDate').text if item.find('pubDate') is not None else ""
|
| 355 |
-
source = "Google News"
|
| 356 |
if " - " in title:
|
| 357 |
parts = title.split(" - ")
|
| 358 |
-
source = parts[-1]
|
| 359 |
title = " - ".join(parts[:-1])
|
| 360 |
results.append({
|
| 361 |
"title": title,
|
|
@@ -367,7 +406,6 @@ async def finance_news():
|
|
| 367 |
news_cache["timestamp"] = time.time()
|
| 368 |
except Exception as e:
|
| 369 |
logger.error(f"News fetch failed: {e}")
|
| 370 |
-
pass
|
| 371 |
|
| 372 |
return results or news_cache.get("data", [])
|
| 373 |
|
|
@@ -395,31 +433,14 @@ async def api_auth(req: AuthRequest, request: Request):
|
|
| 395 |
except ValueError as e:
|
| 396 |
raise HTTPException(status_code=429, detail=str(e))
|
| 397 |
|
| 398 |
-
@app.get("/api/debug-session")
|
| 399 |
-
async def debug_session(request: Request):
|
| 400 |
-
"""Diagnostic: shows raw cookie and session validity. Remove before production."""
|
| 401 |
-
raw = request.cookies.get("we_session", "NOT_SET")
|
| 402 |
-
stripped = raw.strip().strip('"').strip("'")
|
| 403 |
-
sessions = _load_sessions()
|
| 404 |
-
purged = _purge_expired(sessions)
|
| 405 |
-
valid = stripped in purged
|
| 406 |
-
entry = purged.get(stripped, {})
|
| 407 |
-
return JSONResponse({
|
| 408 |
-
"raw_cookie": raw[:60],
|
| 409 |
-
"stripped_cookie": stripped[:60],
|
| 410 |
-
"valid": valid,
|
| 411 |
-
"session_count": len(purged),
|
| 412 |
-
"session_keys_sample": list(purged.keys())[:3],
|
| 413 |
-
"entry": entry,
|
| 414 |
-
"sessions_file_exists": os.path.exists(_SESSION_FILE),
|
| 415 |
-
})
|
| 416 |
-
|
| 417 |
class AdminKeyGenRequest(BaseModel):
|
| 418 |
admin_key: str
|
| 419 |
hours: int = 1
|
| 420 |
|
| 421 |
class AdminRevokeRequest(BaseModel):
|
| 422 |
admin_key: str
|
|
|
|
|
|
|
| 423 |
target_key: str
|
| 424 |
|
| 425 |
@app.post("/api/admin/generate")
|
|
@@ -443,10 +464,24 @@ async def admin_list_keys(admin_key: str = Header(...)):
|
|
| 443 |
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
| 444 |
return {"keys": keys}
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
@app.post("/api/admin/revoke_all")
|
| 447 |
async def admin_revoke_all(req: AdminRevokeRequest):
|
| 448 |
if req.admin_key != access_manager.MASTER_KEY:
|
| 449 |
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
|
|
|
|
|
|
| 450 |
keys = access_manager.get_all_keys(req.admin_key)
|
| 451 |
count = 0
|
| 452 |
for k, v in keys.items():
|
|
@@ -533,6 +568,7 @@ async def generate_portfolio(req: PortfolioRequest, x_access_key: Optional[str]
|
|
| 533 |
'single_asset_min': -1.0 if request.allow_shorting else 0.0,
|
| 534 |
'tax_enabled': request.tax_enabled,
|
| 535 |
'garch_enabled': request.garch_enabled,
|
|
|
|
| 536 |
'custom_constraints': request.custom_constraints
|
| 537 |
}
|
| 538 |
|
|
@@ -578,6 +614,7 @@ async def generate_portfolio(req: PortfolioRequest, x_access_key: Optional[str]
|
|
| 578 |
BACKGROUND_TASKS[tid]["status"] = "completed"
|
| 579 |
BACKGROUND_TASKS[tid]["message"] = "Report generated."
|
| 580 |
BACKGROUND_TASKS[tid]["target_weights"] = result.get("target_weights", {})
|
|
|
|
| 581 |
else:
|
| 582 |
import requests
|
| 583 |
hf_url = os.getenv("HF_BACKEND_URL", "https://engineportf-portfolio-opt.hf.space").rstrip('/')
|
|
@@ -634,6 +671,7 @@ async def generate_portfolio(req: PortfolioRequest, x_access_key: Optional[str]
|
|
| 634 |
|
| 635 |
if s_data["status"] == "completed":
|
| 636 |
BACKGROUND_TASKS[tid]["target_weights"] = s_data.get("target_weights", {})
|
|
|
|
| 637 |
|
| 638 |
# Download the completed HTML report from HF to Render
|
| 639 |
report_res = requests.get(f"{hf_url}/report")
|
|
@@ -685,24 +723,36 @@ async def chat_with_portfolio(req: ChatRequest, x_access_key: Optional[str] = He
|
|
| 685 |
return {"status": "error", "detail": "AI is disabled. Please add 'HF_TOKEN' to your Hugging Face Space Secrets to enable the AI."}
|
| 686 |
|
| 687 |
system_prompt = (
|
| 688 |
-
"
|
|
|
|
| 689 |
"Your goal is to help the user analyze their portfolio, explain mathematical models, "
|
| 690 |
-
"and act as a highly intelligent conversational partner. "
|
|
|
|
| 691 |
"You can answer general finance questions, write python code, or explain concepts simply if asked. "
|
| 692 |
"Never give explicit financial advice (e.g. 'You must buy this stock'). "
|
| 693 |
-
"Be
|
| 694 |
-
"
|
| 695 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
)
|
| 697 |
|
| 698 |
# Sassy persona for Master Key users
|
| 699 |
-
max_t =
|
| 700 |
is_master = access_manager.is_master_key(x_access_key)
|
| 701 |
if is_master:
|
| 702 |
-
max_t =
|
| 703 |
system_prompt += (
|
| 704 |
" [CRITICAL INSTRUCTION: The user communicating with you is the Master Admin. "
|
| 705 |
-
"You must adopt a
|
| 706 |
"However, if the Master Admin explicitly commands you to stop being sassy or to be serious, you MUST instantly drop the act and obey unconditionally.]"
|
| 707 |
)
|
| 708 |
|
|
@@ -714,6 +764,21 @@ async def chat_with_portfolio(req: ChatRequest, x_access_key: Optional[str] = He
|
|
| 714 |
|
| 715 |
context_str = f"\n\nUser's Current Portfolio Context:\n{req.portfolio_context}" if req.portfolio_context else ""
|
| 716 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
messages = [
|
| 718 |
{"role": "system", "content": system_prompt + context_str}
|
| 719 |
]
|
|
@@ -736,7 +801,7 @@ async def chat_with_portfolio(req: ChatRequest, x_access_key: Optional[str] = He
|
|
| 736 |
messages=messages,
|
| 737 |
model=model_id,
|
| 738 |
max_tokens=max_t,
|
| 739 |
-
temperature=0.
|
| 740 |
)
|
| 741 |
return {"status": "success", "response": response.choices[0].message.content.strip(), "model": model_id}
|
| 742 |
except Exception as e:
|
|
@@ -779,7 +844,8 @@ async def generate_strategy(req: StrategyRequest, x_access_key: Optional[str] =
|
|
| 779 |
"risk (integer 1-10, default 5), "
|
| 780 |
"model (integer 1-7, 1=CAPM, 2=Black-Litterman, 3=Bayesian Shrinkage, 4=Fama-French, 5=XGBoost, 6=SPO+, 7=HMM), "
|
| 781 |
"allocation_engine (integer 1-3, 1=CVaR, 2=HRP, 3=ERC), "
|
| 782 |
-
"allow_shorting (boolean), tax_enabled (boolean), garch_enabled (boolean)
|
|
|
|
| 783 |
)
|
| 784 |
|
| 785 |
from huggingface_hub import InferenceClient
|
|
@@ -862,40 +928,44 @@ async def get_report():
|
|
| 862 |
raise HTTPException(status_code=404, detail="Report not generated yet.")
|
| 863 |
|
| 864 |
def _alert_daemon():
|
| 865 |
-
"""Background daemon to check for market drops."""
|
| 866 |
import time
|
|
|
|
|
|
|
| 867 |
while True:
|
| 868 |
try:
|
| 869 |
# Wake up every 1 hour (3600 seconds)
|
| 870 |
time.sleep(3600)
|
| 871 |
|
| 872 |
-
#
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
|
| 880 |
-
|
| 881 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
except Exception as e:
|
| 883 |
pass # Suppress daemon errors
|
| 884 |
|
| 885 |
-
def _keep_alive_daemon():
|
| 886 |
-
"""Background daemon to keep the Hugging Face Space awake."""
|
| 887 |
-
import time
|
| 888 |
-
import requests
|
| 889 |
-
while True:
|
| 890 |
-
try:
|
| 891 |
-
# Wake up every 5 minutes (300 seconds)
|
| 892 |
-
time.sleep(300)
|
| 893 |
-
|
| 894 |
-
# Ping the public URL to keep the load balancer active
|
| 895 |
-
requests.get("https://michaliskoustis2005-byte-portfolio-engine.hf.space/")
|
| 896 |
-
except Exception:
|
| 897 |
-
pass
|
| 898 |
-
|
| 899 |
@app.on_event("startup")
|
| 900 |
def startup_event():
|
| 901 |
import threading
|
|
@@ -903,10 +973,6 @@ def startup_event():
|
|
| 903 |
# Start the background alert daemon
|
| 904 |
alert_thread = threading.Thread(target=_alert_daemon, daemon=True)
|
| 905 |
alert_thread.start()
|
| 906 |
-
|
| 907 |
-
# Start the keep-alive daemon
|
| 908 |
-
keep_alive_thread = threading.Thread(target=_keep_alive_daemon, daemon=True)
|
| 909 |
-
keep_alive_thread.start()
|
| 910 |
|
| 911 |
if __name__ == "__main__":
|
| 912 |
import uvicorn
|
|
@@ -915,6 +981,7 @@ if __name__ == "__main__":
|
|
| 915 |
|
| 916 |
class AdminClearRequest(BaseModel):
|
| 917 |
admin_key: str
|
|
|
|
| 918 |
|
| 919 |
@app.post("/api/admin/clear_backtests")
|
| 920 |
def admin_clear_backtests(req: AdminClearRequest, db: Session = Depends(get_db)):
|
|
|
|
| 34 |
except ImportError:
|
| 35 |
pass
|
| 36 |
|
|
|
|
| 37 |
|
| 38 |
try:
|
| 39 |
from diagnostics import TraceManager
|
|
|
|
| 55 |
from config import OUTPUT_DIR, logger
|
| 56 |
import access_manager
|
| 57 |
|
| 58 |
+
class FileBackedDict(dict):
|
| 59 |
+
def __init__(self, filename):
|
| 60 |
+
super().__init__()
|
| 61 |
+
self.filename = filename
|
| 62 |
+
self.lock = threading.Lock()
|
| 63 |
+
self.load()
|
| 64 |
+
|
| 65 |
+
def load(self):
|
| 66 |
+
if os.path.exists(self.filename):
|
| 67 |
+
try:
|
| 68 |
+
with open(self.filename, 'r', encoding='utf-8') as f:
|
| 69 |
+
data = json.load(f)
|
| 70 |
+
super().update(data)
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Failed to load tasks from {self.filename}: {e}")
|
| 73 |
+
|
| 74 |
+
def save(self):
|
| 75 |
+
with self.lock:
|
| 76 |
+
try:
|
| 77 |
+
with open(self.filename, 'w', encoding='utf-8') as f:
|
| 78 |
+
json.dump(dict(self), f)
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(f"Failed to save tasks to {self.filename}: {e}")
|
| 81 |
+
|
| 82 |
+
def __setitem__(self, key, value):
|
| 83 |
+
super().__setitem__(key, value)
|
| 84 |
+
self.save()
|
| 85 |
+
|
| 86 |
+
def __delitem__(self, key):
|
| 87 |
+
super().__delitem__(key)
|
| 88 |
+
self.save()
|
| 89 |
+
|
| 90 |
+
def update(self, *args, **kwargs):
|
| 91 |
+
super().update(*args, **kwargs)
|
| 92 |
+
self.save()
|
| 93 |
+
|
| 94 |
+
BACKGROUND_TASKS = FileBackedDict(os.path.join(OUTPUT_DIR, "tasks.json"))
|
| 95 |
+
|
| 96 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 97 |
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
| 98 |
|
|
|
|
| 183 |
allow_shorting: bool = True
|
| 184 |
tax_enabled: bool = False
|
| 185 |
garch_enabled: bool = True
|
| 186 |
+
currency: str = "$"
|
| 187 |
custom_constraints: Optional[List[dict]] = None
|
| 188 |
|
| 189 |
class ChatHistoryItem(BaseModel):
|
|
|
|
| 375 |
@app.get("/api/finance_news")
|
| 376 |
async def finance_news():
|
| 377 |
global news_cache
|
| 378 |
+
if time.time() - news_cache.get("timestamp", 0) < 300 and news_cache.get("data"):
|
| 379 |
return news_cache["data"]
|
| 380 |
|
| 381 |
results = []
|
| 382 |
try:
|
| 383 |
+
import requests
|
| 384 |
import xml.etree.ElementTree as ET
|
| 385 |
+
import urllib.parse
|
| 386 |
+
q = urllib.parse.quote("stock market finance")
|
| 387 |
+
url = f"https://news.google.com/rss/search?q={q}&hl=en-US&gl=US&ceid=US:en"
|
| 388 |
+
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36'}
|
| 389 |
+
res = requests.get(url, headers=headers, timeout=10)
|
| 390 |
+
if res.status_code == 200:
|
| 391 |
+
root = ET.fromstring(res.content)
|
| 392 |
for item in root.findall('./channel/item')[:15]:
|
| 393 |
+
title = item.find('title').text if item.find('title') is not None else "No Title"
|
| 394 |
pub_date = item.find('pubDate').text if item.find('pubDate') is not None else ""
|
| 395 |
+
source = item.find('source').text if item.find('source') is not None else "Google News"
|
| 396 |
if " - " in title:
|
| 397 |
parts = title.split(" - ")
|
|
|
|
| 398 |
title = " - ".join(parts[:-1])
|
| 399 |
results.append({
|
| 400 |
"title": title,
|
|
|
|
| 406 |
news_cache["timestamp"] = time.time()
|
| 407 |
except Exception as e:
|
| 408 |
logger.error(f"News fetch failed: {e}")
|
|
|
|
| 409 |
|
| 410 |
return results or news_cache.get("data", [])
|
| 411 |
|
|
|
|
| 433 |
except ValueError as e:
|
| 434 |
raise HTTPException(status_code=429, detail=str(e))
|
| 435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
class AdminKeyGenRequest(BaseModel):
|
| 437 |
admin_key: str
|
| 438 |
hours: int = 1
|
| 439 |
|
| 440 |
class AdminRevokeRequest(BaseModel):
|
| 441 |
admin_key: str
|
| 442 |
+
target_key: str = ""
|
| 443 |
+
confirm_token: str = ""
|
| 444 |
target_key: str
|
| 445 |
|
| 446 |
@app.post("/api/admin/generate")
|
|
|
|
| 464 |
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
| 465 |
return {"keys": keys}
|
| 466 |
|
| 467 |
+
|
| 468 |
+
import time, hashlib
|
| 469 |
+
def get_action_token(action: str, admin_key: str) -> str:
|
| 470 |
+
window = int(time.time() / 120)
|
| 471 |
+
return hashlib.sha256(f"{admin_key}:{action}:{window}".encode()).hexdigest()
|
| 472 |
+
|
| 473 |
+
@app.get("/api/admin/action_token")
|
| 474 |
+
async def get_admin_action_token(action: str, admin_key: str = Header(...)):
|
| 475 |
+
if admin_key != access_manager.MASTER_KEY:
|
| 476 |
+
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
| 477 |
+
return {"token": get_action_token(action, admin_key)}
|
| 478 |
+
|
| 479 |
@app.post("/api/admin/revoke_all")
|
| 480 |
async def admin_revoke_all(req: AdminRevokeRequest):
|
| 481 |
if req.admin_key != access_manager.MASTER_KEY:
|
| 482 |
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
| 483 |
+
if getattr(req, "confirm_token", "") != get_action_token("revoke_all", req.admin_key):
|
| 484 |
+
raise HTTPException(status_code=403, detail="Invalid or expired confirmation token")
|
| 485 |
keys = access_manager.get_all_keys(req.admin_key)
|
| 486 |
count = 0
|
| 487 |
for k, v in keys.items():
|
|
|
|
| 568 |
'single_asset_min': -1.0 if request.allow_shorting else 0.0,
|
| 569 |
'tax_enabled': request.tax_enabled,
|
| 570 |
'garch_enabled': request.garch_enabled,
|
| 571 |
+
'currency_symbol': request.currency,
|
| 572 |
'custom_constraints': request.custom_constraints
|
| 573 |
}
|
| 574 |
|
|
|
|
| 614 |
BACKGROUND_TASKS[tid]["status"] = "completed"
|
| 615 |
BACKGROUND_TASKS[tid]["message"] = "Report generated."
|
| 616 |
BACKGROUND_TASKS[tid]["target_weights"] = result.get("target_weights", {})
|
| 617 |
+
BACKGROUND_TASKS[tid]["stats"] = result.get("stats", {})
|
| 618 |
else:
|
| 619 |
import requests
|
| 620 |
hf_url = os.getenv("HF_BACKEND_URL", "https://engineportf-portfolio-opt.hf.space").rstrip('/')
|
|
|
|
| 671 |
|
| 672 |
if s_data["status"] == "completed":
|
| 673 |
BACKGROUND_TASKS[tid]["target_weights"] = s_data.get("target_weights", {})
|
| 674 |
+
BACKGROUND_TASKS[tid]["stats"] = s_data.get("stats", {})
|
| 675 |
|
| 676 |
# Download the completed HTML report from HF to Render
|
| 677 |
report_res = requests.get(f"{hf_url}/report")
|
|
|
|
| 723 |
return {"status": "error", "detail": "AI is disabled. Please add 'HF_TOKEN' to your Hugging Face Space Secrets to enable the AI."}
|
| 724 |
|
| 725 |
system_prompt = (
|
| 726 |
+
"Your name is NOVA, an elite, autonomous quantitative analyst AI copilot built into the Portfolio Engine. "
|
| 727 |
+
"You must retain and stay on your memory across the conversation. "
|
| 728 |
"Your goal is to help the user analyze their portfolio, explain mathematical models, "
|
| 729 |
+
"and act as a highly intelligent, proactive conversational partner and decision maker. "
|
| 730 |
+
"You are a copilot, not a mere helper. You have full visibility into the user's portfolio metrics and HTML UI context. "
|
| 731 |
"You can answer general finance questions, write python code, or explain concepts simply if asked. "
|
| 732 |
"Never give explicit financial advice (e.g. 'You must buy this stock'). "
|
| 733 |
+
"Be creative, smart, natural, and adapt to the user's tone. "
|
| 734 |
+
"CRITICAL INSTRUCTION: When discussing the user's portfolio, you MUST act as an elite quantitative partner. You MUST extract and explicitly quote the exact metrics, ratios, and statistics provided in your context (e.g., Sharpe Ratio, Max Drawdown, VaR, correlations, p-values, weights). Identify hidden risks or opportunities that the user is missing based on these precise mathematical figures. Do not give generic advice without backing it up with the specific numbers from their portfolio.\n\n"
|
| 735 |
+
"*** CRITICAL REASONING & ACTION PROTOCOL ***\n"
|
| 736 |
+
"1. STEP-BY-STEP REASONING: Your reasoning applies to EVERY kind of conversation, decision, or query, unless the user explicitly asks you not to use reasoning. "
|
| 737 |
+
"Before responding or making any complex decision or action, you MUST think out loud step-by-step inside `<reasoning>` tags. "
|
| 738 |
+
"For example: `<reasoning>The user wants to invest in Greece in Euros. I should select the .AT suffix for Greek stocks and set currency to β¬.</reasoning>`\n"
|
| 739 |
+
"2. EXECUTING ACTIONS: If the user asks you to build, configure, or run a portfolio for them (e.g., 'I want to invest in tech', 'build me a greek portfolio in euros'), "
|
| 740 |
+
"you MUST append a special execution block to the end of your text response (AFTER your reasoning).\n"
|
| 741 |
+
"The syntax is exactly: `<<<ACT: {\"tickers\": \"HTO.AT, EUROB.AT\", \"risk\": 5.0, \"model\": 6, \"currency\": \"β¬\"}>>>`\n"
|
| 742 |
+
"The JSON keys can include: 'tickers' (comma separated string of symbols, including equities with regional suffixes like .AT for Greece, futures like ES=F, NQ=F, ZB=F, CL=F, GC=F, and direct bonds like US_T10Y, TBILL_3M, CORP_5Y), "
|
| 743 |
+
"'risk' (float from 1.0 to 10.0), 'model' (integer 1-7 for the return model), and 'currency' (e.g., '$', 'β¬', 'Β£', 'Β₯'). "
|
| 744 |
+
"PROACTIVELY SUGGEST allocating to futures contracts or direct bonds to the user to diversify their portfolio if it makes mathematical sense. "
|
| 745 |
+
"The UI will automatically parse this JSON and run the engine on behalf of the user."
|
| 746 |
)
|
| 747 |
|
| 748 |
# Sassy persona for Master Key users
|
| 749 |
+
max_t = 4096
|
| 750 |
is_master = access_manager.is_master_key(x_access_key)
|
| 751 |
if is_master:
|
| 752 |
+
max_t = 8192
|
| 753 |
system_prompt += (
|
| 754 |
" [CRITICAL INSTRUCTION: The user communicating with you is the Master Admin. "
|
| 755 |
+
"You must adopt a sassy, highly confident, and sharp-witted ironic persona throughout the entire conversation. "
|
| 756 |
"However, if the Master Admin explicitly commands you to stop being sassy or to be serious, you MUST instantly drop the act and obey unconditionally.]"
|
| 757 |
)
|
| 758 |
|
|
|
|
| 764 |
|
| 765 |
context_str = f"\n\nUser's Current Portfolio Context:\n{req.portfolio_context}" if req.portfolio_context else ""
|
| 766 |
|
| 767 |
+
# Inject HTML Report Content if it exists
|
| 768 |
+
from constants import OUTPUT_DIR
|
| 769 |
+
import re
|
| 770 |
+
report_path = os.path.join(OUTPUT_DIR, "portfolio_report.html")
|
| 771 |
+
if os.path.exists(report_path):
|
| 772 |
+
try:
|
| 773 |
+
with open(report_path, "r", encoding="utf-8") as f:
|
| 774 |
+
html_content = f.read()
|
| 775 |
+
# Extract the inner text roughly to save tokens
|
| 776 |
+
text_content = re.sub(r'<[^>]+>', ' ', html_content)
|
| 777 |
+
text_content = re.sub(r'\s+', ' ', text_content).strip()
|
| 778 |
+
# Take the first 3000 chars to avoid blowing up context window
|
| 779 |
+
context_str += f"\n\nLatest Generated Report Metrics:\n{text_content[:3000]}"
|
| 780 |
+
except Exception as e:
|
| 781 |
+
pass
|
| 782 |
messages = [
|
| 783 |
{"role": "system", "content": system_prompt + context_str}
|
| 784 |
]
|
|
|
|
| 801 |
messages=messages,
|
| 802 |
model=model_id,
|
| 803 |
max_tokens=max_t,
|
| 804 |
+
temperature=0.7
|
| 805 |
)
|
| 806 |
return {"status": "success", "response": response.choices[0].message.content.strip(), "model": model_id}
|
| 807 |
except Exception as e:
|
|
|
|
| 844 |
"risk (integer 1-10, default 5), "
|
| 845 |
"model (integer 1-7, 1=CAPM, 2=Black-Litterman, 3=Bayesian Shrinkage, 4=Fama-French, 5=XGBoost, 6=SPO+, 7=HMM), "
|
| 846 |
"allocation_engine (integer 1-3, 1=CVaR, 2=HRP, 3=ERC), "
|
| 847 |
+
"allow_shorting (boolean), tax_enabled (boolean), garch_enabled (boolean), "
|
| 848 |
+
"base_currency (string, e.g. 'USD', 'EUR', 'GBP')."
|
| 849 |
)
|
| 850 |
|
| 851 |
from huggingface_hub import InferenceClient
|
|
|
|
| 928 |
raise HTTPException(status_code=404, detail="Report not generated yet.")
|
| 929 |
|
| 930 |
def _alert_daemon():
|
| 931 |
+
"""Background daemon to check for market drops and perform monthly maintenance."""
|
| 932 |
import time
|
| 933 |
+
last_cleanup = time.time()
|
| 934 |
+
|
| 935 |
while True:
|
| 936 |
try:
|
| 937 |
# Wake up every 1 hour (3600 seconds)
|
| 938 |
time.sleep(3600)
|
| 939 |
|
| 940 |
+
# 1. Check for SPY drops
|
| 941 |
+
try:
|
| 942 |
+
ticker = yf.Ticker("SPY")
|
| 943 |
+
hist = ticker.history(period="2d")
|
| 944 |
+
if len(hist) >= 2:
|
| 945 |
+
current = float(hist['Close'].iloc[-1])
|
| 946 |
+
prev = float(hist['Close'].iloc[-2])
|
| 947 |
+
pct_change = ((current - prev) / prev) * 100
|
| 948 |
+
|
| 949 |
+
if pct_change <= -5.0:
|
| 950 |
+
access_manager.send_telegram_alert(f"π¨ **MARKET ALERT**\nSPY has dropped by {pct_change:.2f}%!\nCheck the portfolio engine.")
|
| 951 |
+
except Exception:
|
| 952 |
+
pass
|
| 953 |
|
| 954 |
+
# 2. Monthly DB/Log Cleanup
|
| 955 |
+
# 30 days = 2592000 seconds
|
| 956 |
+
if time.time() - last_cleanup > 2592000:
|
| 957 |
+
try:
|
| 958 |
+
logger.info("Performing monthly database and log cleanup...")
|
| 959 |
+
# access_manager should clean up its expired keys and sync to Redis
|
| 960 |
+
if hasattr(access_manager, 'cleanup_expired_keys'):
|
| 961 |
+
access_manager.cleanup_expired_keys()
|
| 962 |
+
last_cleanup = time.time()
|
| 963 |
+
except Exception as e:
|
| 964 |
+
logger.error(f"Monthly cleanup failed: {e}")
|
| 965 |
+
|
| 966 |
except Exception as e:
|
| 967 |
pass # Suppress daemon errors
|
| 968 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 969 |
@app.on_event("startup")
|
| 970 |
def startup_event():
|
| 971 |
import threading
|
|
|
|
| 973 |
# Start the background alert daemon
|
| 974 |
alert_thread = threading.Thread(target=_alert_daemon, daemon=True)
|
| 975 |
alert_thread.start()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
|
| 977 |
if __name__ == "__main__":
|
| 978 |
import uvicorn
|
|
|
|
| 981 |
|
| 982 |
class AdminClearRequest(BaseModel):
|
| 983 |
admin_key: str
|
| 984 |
+
confirm_token: str = ""
|
| 985 |
|
| 986 |
@app.post("/api/admin/clear_backtests")
|
| 987 |
def admin_clear_backtests(req: AdminClearRequest, db: Session = Depends(get_db)):
|
audit_reproducibility.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
audit_reproducibility.py
|
| 3 |
-
========================
|
| 4 |
-
Run this BEFORE trusting any Model 5 (ML Stacking) output.
|
| 5 |
-
|
| 6 |
-
Usage:
|
| 7 |
-
python audit_reproducibility.py --tickers AAPL TLT JPM SPY --runs 3
|
| 8 |
-
|
| 9 |
-
What it does:
|
| 10 |
-
Runs the full forecast+optimization pipeline N times with identical inputs
|
| 11 |
-
and measures how much the outputs drift. If max weight deviation > 0.5pp,
|
| 12 |
-
the run is flagged as non-deterministic and should not be trusted.
|
| 13 |
-
|
| 14 |
-
After applying the n_jobs=1 fix in models.py, this should show 0.000 drift
|
| 15 |
-
on every metric. If it still shows drift, there is another source of
|
| 16 |
-
non-determinism that needs to be found.
|
| 17 |
-
"""
|
| 18 |
-
|
| 19 |
-
import sys
|
| 20 |
-
import os
|
| 21 |
-
import argparse
|
| 22 |
-
import copy
|
| 23 |
-
import numpy as np
|
| 24 |
-
import pandas as pd
|
| 25 |
-
import sqlite3
|
| 26 |
-
|
| 27 |
-
_this_dir = os.path.dirname(os.path.abspath(__file__))
|
| 28 |
-
sys.path.insert(0, _this_dir)
|
| 29 |
-
|
| 30 |
-
from config import load_config, Color
|
| 31 |
-
from core_types import PortfolioState
|
| 32 |
-
from data import fetch_risk_free_rate
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def run_single_forecast(returns_df, bench_rets, cfg, model=5, run_id=0):
|
| 36 |
-
"""Run one complete forecast+optimization and return the weights."""
|
| 37 |
-
from solver import build_and_optimize
|
| 38 |
-
|
| 39 |
-
tickers = list(returns_df.columns)
|
| 40 |
-
state = PortfolioState.empty(tickers)
|
| 41 |
-
|
| 42 |
-
opt_res = build_and_optimize(
|
| 43 |
-
returns_df=returns_df,
|
| 44 |
-
benchmark_rets=bench_rets,
|
| 45 |
-
risk_input=5,
|
| 46 |
-
risk_factor=3.0,
|
| 47 |
-
state=state,
|
| 48 |
-
cfg=cfg,
|
| 49 |
-
model=model,
|
| 50 |
-
allocation_engine=1,
|
| 51 |
-
silent=True
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
weights = opt_res.weights
|
| 55 |
-
exp_rets = opt_res.expected_returns
|
| 56 |
-
cov_mat = opt_res.covariance_matrix
|
| 57 |
-
|
| 58 |
-
w_risky = weights.drop(labels=["CASH"], errors="ignore")
|
| 59 |
-
opt_vol = float(np.sqrt(
|
| 60 |
-
w_risky.reindex(cov_mat.columns).fillna(0).values
|
| 61 |
-
@ cov_mat.values
|
| 62 |
-
@ w_risky.reindex(cov_mat.columns).fillna(0).values
|
| 63 |
-
))
|
| 64 |
-
opt_ret = float(
|
| 65 |
-
w_risky @ exp_rets.reindex(w_risky.index).fillna(0)
|
| 66 |
-
) + (float(weights.get("CASH", 0)) * cfg["risk_free_rate"])
|
| 67 |
-
|
| 68 |
-
return {
|
| 69 |
-
"run": run_id,
|
| 70 |
-
"weights": weights,
|
| 71 |
-
"exp_ret": opt_ret,
|
| 72 |
-
"opt_vol": opt_vol,
|
| 73 |
-
"exp_rets": exp_rets,
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def audit(tickers, runs, model):
|
| 78 |
-
cfg = load_config()
|
| 79 |
-
cfg.update({
|
| 80 |
-
"garch_enabled": False, # Isolate ML non-determinism only
|
| 81 |
-
"cvar_enabled": False,
|
| 82 |
-
"tax_enabled": False,
|
| 83 |
-
"dynamic_risk": False,
|
| 84 |
-
"hmm_regime": False,
|
| 85 |
-
"sector_map": {t: "Other" for t in tickers},
|
| 86 |
-
"sector_limit": 1.0,
|
| 87 |
-
"single_asset_min": 0.0,
|
| 88 |
-
"single_asset_max": 0.40,
|
| 89 |
-
})
|
| 90 |
-
|
| 91 |
-
# Load returns from local SQLite cache
|
| 92 |
-
import os
|
| 93 |
-
from config import OUTPUT_DIR
|
| 94 |
-
conn = sqlite3.connect(os.path.join(OUTPUT_DIR, "finance_data.db"))
|
| 95 |
-
all_rets = {}
|
| 96 |
-
for t in tickers:
|
| 97 |
-
df = pd.read_sql(
|
| 98 |
-
"SELECT date, close_price FROM daily_prices WHERE ticker=? ORDER BY date",
|
| 99 |
-
conn, params=(t,)
|
| 100 |
-
)
|
| 101 |
-
if not df.empty:
|
| 102 |
-
df["date"] = pd.to_datetime(df["date"])
|
| 103 |
-
s = df.set_index("date")["close_price"].pct_change().dropna()
|
| 104 |
-
if len(s) > 504:
|
| 105 |
-
all_rets[t] = s
|
| 106 |
-
conn.close()
|
| 107 |
-
|
| 108 |
-
if len(all_rets) < 2:
|
| 109 |
-
print(f"{Color.RED}Not enough cached data. Run the main engine first to populate the database.{Color.RESET}")
|
| 110 |
-
return
|
| 111 |
-
|
| 112 |
-
returns_df = pd.DataFrame(all_rets).dropna()
|
| 113 |
-
bench_rets = returns_df.mean(axis=1) # equal-weight proxy if SPY missing
|
| 114 |
-
|
| 115 |
-
cfg["risk_free_rate"] = fetch_risk_free_rate(
|
| 116 |
-
cfg.get("benchmarks", {}).get("risk_free", "^TNX"), 0.04
|
| 117 |
-
)
|
| 118 |
-
|
| 119 |
-
print(f"\n{Color.CYAN}Running {runs} identical forecasts to measure reproducibility...{Color.RESET}")
|
| 120 |
-
print(f" Tickers: {tickers} | Model: {model} | GARCH: OFF | HMM: OFF\n")
|
| 121 |
-
|
| 122 |
-
results = []
|
| 123 |
-
for i in range(runs):
|
| 124 |
-
try:
|
| 125 |
-
r = run_single_forecast(returns_df, bench_rets, copy.deepcopy(cfg), model=model, run_id=i + 1)
|
| 126 |
-
results.append(r)
|
| 127 |
-
w_str = " ".join(f"{t}={float(r['weights'].get(t, 0))*100:.2f}%" for t in tickers)
|
| 128 |
-
print(f" Run {i+1}: {w_str} | Ret={r['exp_ret']:+.4f} | Vol={r['opt_vol']:.4f}")
|
| 129 |
-
except Exception as e:
|
| 130 |
-
print(f" {Color.RED}Run {i+1} FAILED: {e}{Color.RESET}")
|
| 131 |
-
|
| 132 |
-
if len(results) < 2:
|
| 133 |
-
print(f"\n{Color.RED}Not enough successful runs to compare.{Color.RESET}")
|
| 134 |
-
return
|
| 135 |
-
|
| 136 |
-
# Compute drift across all runs
|
| 137 |
-
all_weights = pd.DataFrame([r["weights"] for r in results]).fillna(0)
|
| 138 |
-
max_drift = float(all_weights.std().max()) * 100 # in percentage points
|
| 139 |
-
|
| 140 |
-
all_rets_vals = [r["exp_ret"] for r in results]
|
| 141 |
-
ret_drift = (max(all_rets_vals) - min(all_rets_vals)) * 10000 # in bps
|
| 142 |
-
|
| 143 |
-
print(f"\n{'='*55}")
|
| 144 |
-
print(" REPRODUCIBILITY AUDIT RESULTS")
|
| 145 |
-
print(f"{'='*55}")
|
| 146 |
-
print(f" Max weight std dev across runs : {max_drift:.4f} pp")
|
| 147 |
-
print(f" Expected return range : {ret_drift:.2f} bps")
|
| 148 |
-
|
| 149 |
-
if max_drift < 0.05:
|
| 150 |
-
print(f"\n {Color.GREEN}β PASS β Results are deterministic. Output is trustworthy.{Color.RESET}")
|
| 151 |
-
elif max_drift < 0.50:
|
| 152 |
-
print(f"\n {Color.YELLOW}β WARNING β Small drift ({max_drift:.2f}pp). Likely solver tolerance, not XGBoost.{Color.RESET}")
|
| 153 |
-
print(" This is acceptable for practical use but investigate the source.")
|
| 154 |
-
else:
|
| 155 |
-
print(f"\n {Color.RED}β FAIL β Large drift ({max_drift:.2f}pp). Results are NOT trustworthy.{Color.RESET}")
|
| 156 |
-
print(" Check that n_jobs=1 in XGBRegressor in models.py.")
|
| 157 |
-
print(" If the fix is applied and drift persists, another RNG source exists.")
|
| 158 |
-
|
| 159 |
-
print("\n Individual weight ranges:")
|
| 160 |
-
for col in all_weights.columns:
|
| 161 |
-
mn = all_weights[col].min() * 100
|
| 162 |
-
mx = all_weights[col].max() * 100
|
| 163 |
-
rng = mx - mn
|
| 164 |
-
flag = f" {Color.RED}β DRIFTING{Color.RESET}" if rng > 0.5 else ""
|
| 165 |
-
print(f" {col:<8} {mn:.2f}% β {mx:.2f}% (range: {rng:.3f}pp){flag}")
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
if __name__ == "__main__":
|
| 169 |
-
parser = argparse.ArgumentParser(description="Reproducibility audit for the portfolio engine.")
|
| 170 |
-
parser.add_argument("--tickers", nargs="+", default=["SPY", "TLT", "AAPL", "JPM"])
|
| 171 |
-
parser.add_argument("--runs", type=int, default=3)
|
| 172 |
-
parser.add_argument("--model", type=int, choices=[1, 2, 3, 4, 5], default=5)
|
| 173 |
-
args = parser.parse_args()
|
| 174 |
-
audit(args.tickers, args.runs, args.model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backtest.py
CHANGED
|
@@ -14,7 +14,7 @@ try:
|
|
| 14 |
except ImportError:
|
| 15 |
_HAS_EXECUTION = False
|
| 16 |
|
| 17 |
-
def expanding_window_backtest(returns_df, spy_rets, capital, rfr, cfg, model, allocation_engine, spread_map, initial_train_days=1260, rebalance_freq=63, ff_df=None, yield_df=None):
|
| 18 |
"""
|
| 19 |
Performs a rigorous out-of-sample expanding window backtest.
|
| 20 |
Inherently applies LotManager for precise HIFO tax lot tracking across time.
|
|
@@ -173,8 +173,12 @@ def expanding_window_backtest(returns_df, spy_rets, capital, rfr, cfg, model, al
|
|
| 173 |
if t == initial_train_days:
|
| 174 |
for i, ticker in enumerate(tickers):
|
| 175 |
if w_arr[i] > 1e-5 and current_capital > 0:
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
shares = (w_arr[i] * current_capital) / px
|
| 179 |
lot_manager.add_lot(ticker, current_date, px, shares)
|
| 180 |
else:
|
|
@@ -184,8 +188,12 @@ def expanding_window_backtest(returns_df, spy_rets, capital, rfr, cfg, model, al
|
|
| 184 |
if local_cfg.get('tax_enabled', False) and current_capital > 1e-4:
|
| 185 |
for i, ticker in enumerate(tickers):
|
| 186 |
w_shift = delta[i]
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
if w_shift < -1e-5:
|
| 191 |
shares_to_sell = abs(w_shift) * current_capital / px
|
|
|
|
| 14 |
except ImportError:
|
| 15 |
_HAS_EXECUTION = False
|
| 16 |
|
| 17 |
+
def expanding_window_backtest(returns_df, spy_rets, capital, rfr, cfg, model, allocation_engine, spread_map, initial_train_days=1260, rebalance_freq=63, ff_df=None, yield_df=None, raw_prices=None):
|
| 18 |
"""
|
| 19 |
Performs a rigorous out-of-sample expanding window backtest.
|
| 20 |
Inherently applies LotManager for precise HIFO tax lot tracking across time.
|
|
|
|
| 173 |
if t == initial_train_days:
|
| 174 |
for i, ticker in enumerate(tickers):
|
| 175 |
if w_arr[i] > 1e-5 and current_capital > 0:
|
| 176 |
+
px = 1.0
|
| 177 |
+
if raw_prices is not None and ticker in raw_prices:
|
| 178 |
+
px = raw_prices[ticker].reindex([current_date], method='ffill').iloc[0]
|
| 179 |
+
else:
|
| 180 |
+
curr_idx = synth_prices.index.get_indexer([current_date], method='ffill')[0]
|
| 181 |
+
px = synth_prices.iloc[curr_idx][ticker]
|
| 182 |
shares = (w_arr[i] * current_capital) / px
|
| 183 |
lot_manager.add_lot(ticker, current_date, px, shares)
|
| 184 |
else:
|
|
|
|
| 188 |
if local_cfg.get('tax_enabled', False) and current_capital > 1e-4:
|
| 189 |
for i, ticker in enumerate(tickers):
|
| 190 |
w_shift = delta[i]
|
| 191 |
+
px = 1.0
|
| 192 |
+
if raw_prices is not None and ticker in raw_prices:
|
| 193 |
+
px = raw_prices[ticker].reindex([current_date], method='ffill').iloc[0]
|
| 194 |
+
else:
|
| 195 |
+
curr_idx = synth_prices.index.get_indexer([current_date], method='ffill')[0]
|
| 196 |
+
px = synth_prices.iloc[curr_idx][ticker]
|
| 197 |
|
| 198 |
if w_shift < -1e-5:
|
| 199 |
shares_to_sell = abs(w_shift) * current_capital / px
|
config.py
CHANGED
|
@@ -17,4 +17,4 @@ from logger import *
|
|
| 17 |
from config_schema import *
|
| 18 |
from config_io import *
|
| 19 |
|
| 20 |
-
MASTER_KEY = os.getenv("MASTER_KEY", "
|
|
|
|
| 17 |
from config_schema import *
|
| 18 |
from config_io import *
|
| 19 |
|
| 20 |
+
MASTER_KEY = os.getenv("MASTER_KEY", "7f8a9e2c4b5d6f1a")
|
constants.py
CHANGED
|
@@ -35,7 +35,7 @@ COST_BASIS_FILE = os.path.join(OUTPUT_DIR, "portfolio_state.json")
|
|
| 35 |
CONFIG_FILE = os.path.join(OUTPUT_DIR, "portfolio_config.json")
|
| 36 |
KEYS_FILE = os.path.join(OUTPUT_DIR, "access_keys.json")
|
| 37 |
|
| 38 |
-
MASTER_KEY = os.getenv("MASTER_KEY", "
|
| 39 |
|
| 40 |
MODEL_NAMES = {
|
| 41 |
1: "CAPM (Capital Asset Pricing Model)",
|
|
|
|
| 35 |
CONFIG_FILE = os.path.join(OUTPUT_DIR, "portfolio_config.json")
|
| 36 |
KEYS_FILE = os.path.join(OUTPUT_DIR, "access_keys.json")
|
| 37 |
|
| 38 |
+
MASTER_KEY = os.getenv("MASTER_KEY", "7f8a9e2c4b5d6f1a")
|
| 39 |
|
| 40 |
MODEL_NAMES = {
|
| 41 |
1: "CAPM (Capital Asset Pricing Model)",
|
core_engine.py
CHANGED
|
@@ -221,6 +221,8 @@ class ValidationBundle:
|
|
| 221 |
dm_results: dict
|
| 222 |
psr_results: dict
|
| 223 |
dsr_results: dict
|
|
|
|
|
|
|
| 224 |
|
| 225 |
@dataclass
|
| 226 |
class OptimizationBundle:
|
|
@@ -334,7 +336,8 @@ class PortfolioPipeline:
|
|
| 334 |
|
| 335 |
oos_eq, oos_bench_curve = expanding_window_backtest(
|
| 336 |
returns_df, bench_rets, self.capital, self.rfr, self.cfg, self.model, self.allocation_engine,
|
| 337 |
-
self.spread_map, initial_train_days=self.OOS_TRAIN_DAYS, rebalance_freq=reb_freq, ff_df=self.ff_df
|
|
|
|
| 338 |
)
|
| 339 |
oos_port_rets = oos_eq.pct_change().dropna()
|
| 340 |
oos_rets_arr = oos_port_rets.values
|
|
@@ -375,7 +378,7 @@ class PortfolioPipeline:
|
|
| 375 |
|
| 376 |
print_validation_report(dm_results, var_results, psr_results, dsr_results, model_name=f"{MODEL_NAMES.get(self.model).split(' ')[0]}")
|
| 377 |
|
| 378 |
-
return ValidationBundle(oos_eq, oos_bench_curve, oos_port_rets, wf_ann_ret, var_results, dm_results, psr_results, dsr_results)
|
| 379 |
|
| 380 |
def optimize(self) -> OptimizationBundle:
|
| 381 |
returns_df = self.data_bundle["returns_df"]
|
|
@@ -444,7 +447,7 @@ class PortfolioPipeline:
|
|
| 444 |
macro = build_macro(prices, raw, self.rfr, self.display_df, opt.weights.values, self.vol_raw, self.cfg)
|
| 445 |
if self.regime_info: macro["hmm_regime"] = self.regime_info
|
| 446 |
|
| 447 |
-
mc_paths, mc_stats = monte_carlo(opt.weights, opt.exp_rets, opt.cov_mat, self.capital, self.cfg, macro, seed=
|
| 448 |
diags = behavioral_diagnostics(opt.weights, self.display_df, opt.cov_mat, self.risk_input, bt_stats["max_dd"])
|
| 449 |
|
| 450 |
overlay_html = ""
|
|
@@ -474,7 +477,7 @@ class PortfolioPipeline:
|
|
| 474 |
rfr_scalar = self.rfr.iloc[-1] if isinstance(self.rfr, pd.Series) else self.rfr
|
| 475 |
curr_sr = israelsen_sharpe(curr_exp_ret - rfr_scalar, curr_vol_val)
|
| 476 |
curr_bt_full = backtest(self.display_df, curr_w_series, self.capital, self.rfr, self.bench_display, self.spread_map, self.cfg, state=self.master_state, betas=opt.betas)
|
| 477 |
-
_, curr_mc_stats = monte_carlo(curr_w_series, opt.exp_rets, opt.cov_mat, self.capital, self.cfg, macro, seed=
|
| 478 |
current_stats = {"exp_ret": curr_exp_ret, "exp_vol": curr_vol_val, "exp_sr": curr_sr, "beta": float(curr_w_series @ opt.betas), "bt": curr_bt_full, "mc": curr_mc_stats}
|
| 479 |
|
| 480 |
# ββ AI Portfolio Sentiment Analysis (FinBERT) ββββββββββββββββββββββ
|
|
@@ -504,7 +507,8 @@ class PortfolioPipeline:
|
|
| 504 |
cvar_components=(c_cvar, t_cvar), stressed_vol=s_vol,
|
| 505 |
factor_exp=factor_exposures, regime_info=self.regime_info,
|
| 506 |
risk_adj=self.risk_adj, dm_results=val.dm_results,
|
| 507 |
-
var_results=val.var_results, overlay_html=overlay_html
|
|
|
|
| 508 |
)
|
| 509 |
if self.cfg.get('_serve', True):
|
| 510 |
serve_report(block=not bool(self.overrides))
|
|
@@ -533,12 +537,21 @@ def run_engine(overrides=None, serve=True, preview_only=False, task_id=None):
|
|
| 533 |
opt_bundle = pipeline.optimize()
|
| 534 |
pipeline.generate_reports(val_bundle, opt_bundle)
|
| 535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
# Return useful attributes for testing/api downstream hooks
|
| 537 |
return {
|
| 538 |
"target_weights": opt_bundle.weights.to_dict(),
|
| 539 |
"expected_returns": opt_bundle.exp_rets.to_dict(),
|
| 540 |
"volatility": opt_bundle.vol,
|
| 541 |
-
"prices": pipeline.data_bundle["prices"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
}
|
| 543 |
|
| 544 |
if __name__ == "__main__":
|
|
|
|
| 221 |
dm_results: dict
|
| 222 |
psr_results: dict
|
| 223 |
dsr_results: dict
|
| 224 |
+
pt_results: dict = None
|
| 225 |
+
lb_results: dict = None
|
| 226 |
|
| 227 |
@dataclass
|
| 228 |
class OptimizationBundle:
|
|
|
|
| 336 |
|
| 337 |
oos_eq, oos_bench_curve = expanding_window_backtest(
|
| 338 |
returns_df, bench_rets, self.capital, self.rfr, self.cfg, self.model, self.allocation_engine,
|
| 339 |
+
self.spread_map, initial_train_days=self.OOS_TRAIN_DAYS, rebalance_freq=reb_freq, ff_df=self.ff_df,
|
| 340 |
+
raw_prices=self.data_bundle["raw"]
|
| 341 |
)
|
| 342 |
oos_port_rets = oos_eq.pct_change().dropna()
|
| 343 |
oos_rets_arr = oos_port_rets.values
|
|
|
|
| 378 |
|
| 379 |
print_validation_report(dm_results, var_results, psr_results, dsr_results, model_name=f"{MODEL_NAMES.get(self.model).split(' ')[0]}")
|
| 380 |
|
| 381 |
+
return ValidationBundle(oos_eq, oos_bench_curve, oos_port_rets, wf_ann_ret, var_results, dm_results, psr_results, dsr_results, pt_results, lb_results)
|
| 382 |
|
| 383 |
def optimize(self) -> OptimizationBundle:
|
| 384 |
returns_df = self.data_bundle["returns_df"]
|
|
|
|
| 447 |
macro = build_macro(prices, raw, self.rfr, self.display_df, opt.weights.values, self.vol_raw, self.cfg)
|
| 448 |
if self.regime_info: macro["hmm_regime"] = self.regime_info
|
| 449 |
|
| 450 |
+
mc_paths, mc_stats = monte_carlo(opt.weights, opt.exp_rets, opt.cov_mat, self.capital, self.cfg, macro, seed=None)
|
| 451 |
diags = behavioral_diagnostics(opt.weights, self.display_df, opt.cov_mat, self.risk_input, bt_stats["max_dd"])
|
| 452 |
|
| 453 |
overlay_html = ""
|
|
|
|
| 477 |
rfr_scalar = self.rfr.iloc[-1] if isinstance(self.rfr, pd.Series) else self.rfr
|
| 478 |
curr_sr = israelsen_sharpe(curr_exp_ret - rfr_scalar, curr_vol_val)
|
| 479 |
curr_bt_full = backtest(self.display_df, curr_w_series, self.capital, self.rfr, self.bench_display, self.spread_map, self.cfg, state=self.master_state, betas=opt.betas)
|
| 480 |
+
_, curr_mc_stats = monte_carlo(curr_w_series, opt.exp_rets, opt.cov_mat, self.capital, self.cfg, macro, seed=None)
|
| 481 |
current_stats = {"exp_ret": curr_exp_ret, "exp_vol": curr_vol_val, "exp_sr": curr_sr, "beta": float(curr_w_series @ opt.betas), "bt": curr_bt_full, "mc": curr_mc_stats}
|
| 482 |
|
| 483 |
# ββ AI Portfolio Sentiment Analysis (FinBERT) ββββββββββββββββββββββ
|
|
|
|
| 507 |
cvar_components=(c_cvar, t_cvar), stressed_vol=s_vol,
|
| 508 |
factor_exp=factor_exposures, regime_info=self.regime_info,
|
| 509 |
risk_adj=self.risk_adj, dm_results=val.dm_results,
|
| 510 |
+
var_results=val.var_results, overlay_html=overlay_html,
|
| 511 |
+
psr_results=val.psr_results, dsr_results=val.dsr_results
|
| 512 |
)
|
| 513 |
if self.cfg.get('_serve', True):
|
| 514 |
serve_report(block=not bool(self.overrides))
|
|
|
|
| 537 |
opt_bundle = pipeline.optimize()
|
| 538 |
pipeline.generate_reports(val_bundle, opt_bundle)
|
| 539 |
|
| 540 |
+
# Calculate MCR for chat context
|
| 541 |
+
w_risky = opt_bundle.weights.drop(labels=['CASH'], errors='ignore')
|
| 542 |
+
mcr_series = {t: w_risky.get(t, 0.0) * opt_bundle.exp_rets.get(t, 0.0) for t in w_risky.index}
|
| 543 |
+
|
| 544 |
# Return useful attributes for testing/api downstream hooks
|
| 545 |
return {
|
| 546 |
"target_weights": opt_bundle.weights.to_dict(),
|
| 547 |
"expected_returns": opt_bundle.exp_rets.to_dict(),
|
| 548 |
"volatility": opt_bundle.vol,
|
| 549 |
+
"prices": pipeline.data_bundle["prices"],
|
| 550 |
+
"stats": {
|
| 551 |
+
"feature_importances": opt_bundle.model_info.get("feature_importances", {}),
|
| 552 |
+
"ai_sentiment": opt_bundle.model_info.get("ai_sentiment", {}),
|
| 553 |
+
"marginal_contribution_to_return": mcr_series
|
| 554 |
+
}
|
| 555 |
}
|
| 556 |
|
| 557 |
if __name__ == "__main__":
|
cvxpy_engine.py
CHANGED
|
@@ -576,9 +576,12 @@ class CVXPYOptimizationEngine:
|
|
| 576 |
relaxation_log.append(log_msg)
|
| 577 |
|
| 578 |
if stage_idx >= 7:
|
| 579 |
-
print(f"\n {Color.YELLOW}
|
| 580 |
if not self.silent:
|
| 581 |
logger.warning("Optimization dropped into 'Unconstrained' mode.")
|
|
|
|
|
|
|
|
|
|
| 582 |
elif stage_idx >= 4:
|
| 583 |
print(f"\n {Color.YELLOW}β WARNING: Optimization hit deep relaxation (Stage {stage_idx}). Constraints heavily modified.{Color.RESET}")
|
| 584 |
if not self.silent:
|
|
|
|
| 576 |
relaxation_log.append(log_msg)
|
| 577 |
|
| 578 |
if stage_idx >= 7:
|
| 579 |
+
print(f"\n {Color.YELLOW}? WARNING: Optimization dropped into 'Unconstrained' mode. Original constraints abandoned due to mathematical infeasibility.{Color.RESET}")
|
| 580 |
if not self.silent:
|
| 581 |
logger.warning("Optimization dropped into 'Unconstrained' mode.")
|
| 582 |
+
if stage_idx >= 8:
|
| 583 |
+
print(f"\n {Color.RED}? ERROR: Halting execution. Refusing to return unconstrained degenerate portfolio.{Color.RESET}")
|
| 584 |
+
return None, None, None, relaxation_log
|
| 585 |
elif stage_idx >= 4:
|
| 586 |
print(f"\n {Color.YELLOW}β WARNING: Optimization hit deep relaxation (Stage {stage_idx}). Constraints heavily modified.{Color.RESET}")
|
| 587 |
if not self.silent:
|
data_repository.py
CHANGED
|
@@ -34,7 +34,18 @@ class DataSnapshot:
|
|
| 34 |
vol_bench: str = "^VIX"
|
| 35 |
rfr_bench: str = "^TNX"
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
class DataRepository:
|
|
|
|
| 38 |
"""
|
| 39 |
Repository layer responsible for fetching, cleaning, and assembling
|
| 40 |
all market data, benchmarks, and portfolio state required by the engine.
|
|
@@ -49,10 +60,29 @@ class DataRepository:
|
|
| 49 |
vol_bench = b.get("volatility", "^VIX")
|
| 50 |
rfr_bench = b.get("risk_free", "^TNX")
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
ff_df = fetch_fama_french_factors() if model_id in [4, 5] else None
|
| 53 |
|
| 54 |
years_to_fetch = self.cfg.get('data_history_years', 15.0)
|
| 55 |
-
valid_tickers = fetch_data(
|
| 56 |
|
| 57 |
self.cfg["risk_free_rate"] = fetch_risk_free_rate(rfr_bench, self.cfg.get("risk_free_rate", 0.05))
|
| 58 |
rfr_series = fetch_risk_free_series(rfr_bench)
|
|
@@ -83,6 +113,24 @@ class DataRepository:
|
|
| 83 |
except Exception as e:
|
| 84 |
logger.warning(f"DB read failed, returning empty context: {e}")
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
bench_rets = raw[eq_bench].pct_change().dropna() if eq_bench in raw else pd.Series(dtype=float)
|
| 87 |
vol_raw = raw.get(vol_bench, None)
|
| 88 |
|
|
@@ -124,8 +172,8 @@ class DataRepository:
|
|
| 124 |
final_tickers = list(returns_df.columns)
|
| 125 |
master_state = PortfolioState.build(final_tickers, prices, legacy_state_dict, self.cfg)
|
| 126 |
|
| 127 |
-
OOS_TEST_DAYS = self.trading_days
|
| 128 |
total_days = len(returns_df)
|
|
|
|
| 129 |
OOS_TRAIN_DAYS = max(100, total_days - OOS_TEST_DAYS)
|
| 130 |
train_yrs = OOS_TRAIN_DAYS / self.trading_days
|
| 131 |
test_yrs = OOS_TEST_DAYS / self.trading_days
|
|
|
|
| 34 |
vol_bench: str = "^VIX"
|
| 35 |
rfr_bench: str = "^TNX"
|
| 36 |
|
| 37 |
+
|
| 38 |
+
def guess_currency(ticker: str) -> str:
|
| 39 |
+
if ticker.endswith('.AT') or ticker.endswith('.DE') or ticker.endswith('.PA') or ticker.endswith('.MI') or ticker.endswith('.AS') or ticker.endswith('.MC'): return 'EUR'
|
| 40 |
+
if ticker.endswith('.L'): return 'GBP'
|
| 41 |
+
if ticker.endswith('.T'): return 'JPY'
|
| 42 |
+
if ticker.endswith('.AX'): return 'AUD'
|
| 43 |
+
if ticker.endswith('.TO'): return 'CAD'
|
| 44 |
+
if ticker.endswith('.SW'): return 'CHF'
|
| 45 |
+
return 'USD'
|
| 46 |
+
|
| 47 |
class DataRepository:
|
| 48 |
+
|
| 49 |
"""
|
| 50 |
Repository layer responsible for fetching, cleaning, and assembling
|
| 51 |
all market data, benchmarks, and portfolio state required by the engine.
|
|
|
|
| 60 |
vol_bench = b.get("volatility", "^VIX")
|
| 61 |
rfr_bench = b.get("risk_free", "^TNX")
|
| 62 |
|
| 63 |
+
# --- Currency Logic ---
|
| 64 |
+
curr_sym = self.cfg.get("currency_symbol", "$")
|
| 65 |
+
currency_map = {'$': 'USD', 'β¬': 'EUR', 'Β£': 'GBP', 'Β₯': 'JPY', 'CHF': 'CHF'}
|
| 66 |
+
base_currency = currency_map.get(curr_sym, 'USD')
|
| 67 |
+
|
| 68 |
+
fx_tickers_needed = set()
|
| 69 |
+
ticker_to_currency = {}
|
| 70 |
+
for t in input_tickers:
|
| 71 |
+
c = guess_currency(t)
|
| 72 |
+
ticker_to_currency[t] = c
|
| 73 |
+
if c != base_currency and c != 'USD':
|
| 74 |
+
fx_tickers_needed.add(f"{c}{base_currency}=X")
|
| 75 |
+
elif c == 'USD' and base_currency != 'USD':
|
| 76 |
+
# e.g. base is EUR, ticker is AAPL(USD), need USDEUR=X
|
| 77 |
+
fx_tickers_needed.add(f"USD{base_currency}=X")
|
| 78 |
+
|
| 79 |
+
augmented_tickers = list(input_tickers) + list(fx_tickers_needed)
|
| 80 |
+
# ----------------------
|
| 81 |
+
|
| 82 |
ff_df = fetch_fama_french_factors() if model_id in [4, 5] else None
|
| 83 |
|
| 84 |
years_to_fetch = self.cfg.get('data_history_years', 15.0)
|
| 85 |
+
valid_tickers = fetch_data(augmented_tickers, b, years=years_to_fetch, cfg=self.cfg.model_dump() if hasattr(self.cfg, 'model_dump') else dict(self.cfg))
|
| 86 |
|
| 87 |
self.cfg["risk_free_rate"] = fetch_risk_free_rate(rfr_bench, self.cfg.get("risk_free_rate", 0.05))
|
| 88 |
rfr_series = fetch_risk_free_series(rfr_bench)
|
|
|
|
| 113 |
except Exception as e:
|
| 114 |
logger.warning(f"DB read failed, returning empty context: {e}")
|
| 115 |
|
| 116 |
+
# --- Apply Currency Conversion ---
|
| 117 |
+
for t in input_tickers:
|
| 118 |
+
if t not in raw: continue
|
| 119 |
+
c = ticker_to_currency.get(t, 'USD')
|
| 120 |
+
if c != base_currency:
|
| 121 |
+
fx_pair = f"{c}{base_currency}=X"
|
| 122 |
+
if c == 'USD':
|
| 123 |
+
fx_pair = f"USD{base_currency}=X"
|
| 124 |
+
if fx_pair in raw:
|
| 125 |
+
# Align indices and multiply
|
| 126 |
+
fx_series = raw[fx_pair]
|
| 127 |
+
aligned_fx = fx_series.reindex(raw[t].index).ffill().bfill()
|
| 128 |
+
raw[t] = raw[t] * aligned_fx
|
| 129 |
+
prices[t] = prices[t] * aligned_fx.iloc[-1]
|
| 130 |
+
else:
|
| 131 |
+
logger.warning(f"FX pair {fx_pair} missing for {t}. Using unadjusted {c} prices.")
|
| 132 |
+
# ---------------------------------
|
| 133 |
+
|
| 134 |
bench_rets = raw[eq_bench].pct_change().dropna() if eq_bench in raw else pd.Series(dtype=float)
|
| 135 |
vol_raw = raw.get(vol_bench, None)
|
| 136 |
|
|
|
|
| 172 |
final_tickers = list(returns_df.columns)
|
| 173 |
master_state = PortfolioState.build(final_tickers, prices, legacy_state_dict, self.cfg)
|
| 174 |
|
|
|
|
| 175 |
total_days = len(returns_df)
|
| 176 |
+
OOS_TEST_DAYS = int(min(total_days * 0.2, 504)) # Dynamic 20% test up to 2 years
|
| 177 |
OOS_TRAIN_DAYS = max(100, total_days - OOS_TEST_DAYS)
|
| 178 |
train_yrs = OOS_TRAIN_DAYS / self.trading_days
|
| 179 |
test_yrs = OOS_TEST_DAYS / self.trading_days
|
deploy_files.py
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
# Deploy helper - token loaded from env (HF_TOKEN) or .env file
|
| 2 |
-
import os
|
| 3 |
-
from huggingface_hub import HfApi
|
| 4 |
-
|
| 5 |
-
token = os.environ.get("HF_TOKEN", "")
|
| 6 |
-
if not token:
|
| 7 |
-
raise RuntimeError("Set HF_TOKEN env variable before running this script.")
|
| 8 |
-
|
| 9 |
-
api = HfApi(token=token)
|
| 10 |
-
repo = "engineportf/portfolio_opt"
|
| 11 |
-
|
| 12 |
-
files = ["app.py", "static/index.html", "static/app.js", "static/login.html", "static/style.css"]
|
| 13 |
-
for f in files:
|
| 14 |
-
api.upload_file(path_or_fileobj=f, path_in_repo=f, repo_id=repo, repo_type="space")
|
| 15 |
-
print(f"Uploaded: {f}")
|
| 16 |
-
|
| 17 |
-
print("Deploy Successful")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
deploy_to_hf.py
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import sys
|
| 3 |
-
from huggingface_hub import HfApi, create_repo
|
| 4 |
-
|
| 5 |
-
def deploy():
|
| 6 |
-
print("\nπ Hugging Face Space Deployment Tool π")
|
| 7 |
-
print("=========================================\n")
|
| 8 |
-
|
| 9 |
-
token = os.getenv("HF_TOKEN")
|
| 10 |
-
if not token:
|
| 11 |
-
print("Error: No HF_TOKEN provided in environment. Exiting.")
|
| 12 |
-
sys.exit(1)
|
| 13 |
-
|
| 14 |
-
username = HfApi(token=token).whoami()["name"]
|
| 15 |
-
repo_name = "portfolio_opt"
|
| 16 |
-
|
| 17 |
-
repo_id = f"{username}/{repo_name}"
|
| 18 |
-
|
| 19 |
-
print(f"\n[1/2] Skipping Space creation (assuming '{repo_id}' exists)...")
|
| 20 |
-
# try:
|
| 21 |
-
# create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", token=token, exist_ok=True)
|
| 22 |
-
# print(" Space created successfully!")
|
| 23 |
-
# except Exception as e:
|
| 24 |
-
# print(f"Error creating space: {e}")
|
| 25 |
-
# sys.exit(1)
|
| 26 |
-
|
| 27 |
-
print(f"\n[2/2] Uploading project files to '{repo_id}'... (This may take a minute)")
|
| 28 |
-
api = HfApi(token=token)
|
| 29 |
-
|
| 30 |
-
# Exclude unnecessary/heavy local folders
|
| 31 |
-
ignore_patterns = ["__pycache__/*", ".git/*", ".pytest_cache/*", ".ruff_cache/*", "PortableGit/*", "*.db", "scratch/*", "output/*", "docs/*"]
|
| 32 |
-
|
| 33 |
-
try:
|
| 34 |
-
api.upload_folder(
|
| 35 |
-
folder_path=".",
|
| 36 |
-
repo_id=repo_id,
|
| 37 |
-
repo_type="space",
|
| 38 |
-
ignore_patterns=ignore_patterns,
|
| 39 |
-
commit_message="Initial Deployment from Local Engine"
|
| 40 |
-
)
|
| 41 |
-
print("\nβ
DEPLOYMENT COMPLETE! β
")
|
| 42 |
-
print(f"Your app is now live (or building) at: https://huggingface.co/spaces/{repo_id}")
|
| 43 |
-
except Exception as e:
|
| 44 |
-
print(f"Error uploading files: {e}")
|
| 45 |
-
|
| 46 |
-
if __name__ == "__main__":
|
| 47 |
-
deploy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docker-compose.yml
CHANGED
|
@@ -39,7 +39,7 @@ services:
|
|
| 39 |
- .:/app
|
| 40 |
ports:
|
| 41 |
- "8080:8080"
|
| 42 |
-
command: uvicorn
|
| 43 |
stop_signal: SIGINT
|
| 44 |
healthcheck:
|
| 45 |
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
|
|
|
| 39 |
- .:/app
|
| 40 |
ports:
|
| 41 |
- "8080:8080"
|
| 42 |
+
command: uvicorn app:app --host 0.0.0.0 --port 8080
|
| 43 |
stop_signal: SIGINT
|
| 44 |
healthcheck:
|
| 45 |
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
erc_engine.py
CHANGED
|
@@ -34,6 +34,10 @@ def exact_risk_parity_allocation(cov_matrix: pd.DataFrame, silent: bool = False)
|
|
| 34 |
# Ensure strict positive semi-definiteness for CVXPY convex solver
|
| 35 |
sigma_vals = make_nearest_psd(cov_matrix.values)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
# Define variables
|
| 38 |
x = cp.Variable(n, nonneg=True)
|
| 39 |
|
|
|
|
| 34 |
# Ensure strict positive semi-definiteness for CVXPY convex solver
|
| 35 |
sigma_vals = make_nearest_psd(cov_matrix.values)
|
| 36 |
|
| 37 |
+
# Add an epsilon diagonal stabilizer to handle near-zero variances gracefully
|
| 38 |
+
epsilon = 1e-8
|
| 39 |
+
sigma_vals = sigma_vals + np.eye(n) * epsilon
|
| 40 |
+
|
| 41 |
# Define variables
|
| 42 |
x = cp.Variable(n, nonneg=True)
|
| 43 |
|
find_nav.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
content = open('static/index.html', 'rb').read().decode('utf-8')
|
| 2 |
-
|
| 3 |
-
# Inject overlay div right after </nav>
|
| 4 |
-
old = '</nav>\r\r\n\r\r\n <!-- App Layout Container -->'
|
| 5 |
-
new = '</nav>\r\n <div class="mobile-nav-overlay" id="mobileNavOverlay" onclick="toggleMobileNav()"></div>\r\n\r\n <!-- App Layout Container -->'
|
| 6 |
-
|
| 7 |
-
if old in content:
|
| 8 |
-
content = content.replace(old, new, 1)
|
| 9 |
-
print('Overlay injected.')
|
| 10 |
-
else:
|
| 11 |
-
# try different CRLF combos
|
| 12 |
-
import re
|
| 13 |
-
pattern = r'</nav>\s+<!-- App Layout Container -->'
|
| 14 |
-
match = re.search(pattern, content)
|
| 15 |
-
if match:
|
| 16 |
-
content = content[:match.start()] + '</nav>\n <div class="mobile-nav-overlay" id="mobileNavOverlay" onclick="toggleMobileNav()"></div>\n\n <!-- App Layout Container -->' + content[match.end():]
|
| 17 |
-
print('Overlay injected via regex.')
|
| 18 |
-
else:
|
| 19 |
-
print('NOT FOUND')
|
| 20 |
-
idx = content.find('</nav>')
|
| 21 |
-
print(repr(content[idx:idx+100]))
|
| 22 |
-
|
| 23 |
-
open('static/index.html', 'w', encoding='utf-8').write(content)
|
| 24 |
-
print('Done.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_app.py
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
|
| 3 |
-
filepath = r"d:\portfolio engine\engine\static\app.js"
|
| 4 |
-
with open(filepath, "r", encoding="utf-8") as f:
|
| 5 |
-
lines = f.readlines()
|
| 6 |
-
|
| 7 |
-
new_lines = []
|
| 8 |
-
skip = False
|
| 9 |
-
for i, line in enumerate(lines):
|
| 10 |
-
if i == 745: # index 745 is line 746
|
| 11 |
-
new_lines.append(" if (config.allocation_engine) document.getElementById('allocation_engine').value = config.allocation_engine;\n")
|
| 12 |
-
new_lines.append(" \n")
|
| 13 |
-
new_lines.append(" if (config.allow_shorting !== undefined) document.getElementById('allow_shorting').checked = config.allow_shorting;\n")
|
| 14 |
-
new_lines.append(" if (config.tax_enabled !== undefined) document.getElementById('tax_enabled').checked = config.tax_enabled;\n")
|
| 15 |
-
new_lines.append(" if (config.garch_enabled !== undefined) document.getElementById('garch_enabled').checked = config.garch_enabled;\n")
|
| 16 |
-
new_lines.append(" \n")
|
| 17 |
-
new_lines.append(" // Trigger animation or feedback\n")
|
| 18 |
-
new_lines.append(" inputEl.style.borderColor = \"#10b981\";\n")
|
| 19 |
-
new_lines.append(" setTimeout(() => inputEl.style.borderColor = \"rgba(255, 255, 255, 0.1)\", 2000);\n")
|
| 20 |
-
new_lines.append(" } else {\n")
|
| 21 |
-
new_lines.append(" alert(data.detail || \"Failed to generate strategy.\");\n")
|
| 22 |
-
new_lines.append(" }\n")
|
| 23 |
-
new_lines.append(" } catch (e) {\n")
|
| 24 |
-
new_lines.append(" console.error(\"AI Strategy Generator failed:\", e);\n")
|
| 25 |
-
new_lines.append(" alert(\"Network error.\");\n")
|
| 26 |
-
new_lines.append(" }\n")
|
| 27 |
-
new_lines.append("\n")
|
| 28 |
-
new_lines.append(" btn.disabled = false;\n")
|
| 29 |
-
new_lines.append(" btn.innerText = origText;\n")
|
| 30 |
-
new_lines.append("}\n")
|
| 31 |
-
new_lines.append("\n")
|
| 32 |
-
skip = True
|
| 33 |
-
|
| 34 |
-
if skip and line.strip().startswith("// --- REPORT FRAME LOGIC ---"):
|
| 35 |
-
skip = False
|
| 36 |
-
|
| 37 |
-
if not skip:
|
| 38 |
-
new_lines.append(line)
|
| 39 |
-
|
| 40 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 41 |
-
f.writelines(new_lines)
|
| 42 |
-
print("Fixed app.js")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_app_py.py
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
|
| 3 |
-
filepath = r"d:\portfolio engine\engine\app.py"
|
| 4 |
-
with open(filepath, "r", encoding="utf-8") as f:
|
| 5 |
-
lines = f.readlines()
|
| 6 |
-
|
| 7 |
-
new_lines = []
|
| 8 |
-
for i, line in enumerate(lines):
|
| 9 |
-
new_lines.append(line)
|
| 10 |
-
if line.strip() == "import traceback":
|
| 11 |
-
new_lines.append("import pandas as pd\n")
|
| 12 |
-
new_lines.append("import logging\n")
|
| 13 |
-
new_lines.append("logger = logging.getLogger(__name__)\n")
|
| 14 |
-
|
| 15 |
-
with open(filepath, "w", encoding="utf-8") as f:
|
| 16 |
-
f.writelines(new_lines)
|
| 17 |
-
print("app.py fixed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_cookie.py
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
content = open('app.py', encoding='utf-8').read()
|
| 2 |
-
|
| 3 |
-
# Fix SameSite (try both possible values it might currently have)
|
| 4 |
-
for old in ['samesite="strict", # CSRF protection',
|
| 5 |
-
'samesite="lax", # lax allows cookie on top-level navigations (strict blocks redirect after login)']:
|
| 6 |
-
if old in content:
|
| 7 |
-
content = content.replace(old, 'samesite="lax", # lax: allows top-level nav (strict blocks post-login redirect)', 1)
|
| 8 |
-
print(f'Replaced: {old[:40]}...')
|
| 9 |
-
break
|
| 10 |
-
else:
|
| 11 |
-
idx = content.find('samesite=')
|
| 12 |
-
print('Context:', repr(content[idx:idx+80]) if idx != -1 else 'NOT FOUND')
|
| 13 |
-
|
| 14 |
-
open('app.py', 'w', encoding='utf-8').write(content)
|
| 15 |
-
print('Done.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_encoding.py
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
import codecs
|
| 2 |
-
with open('static/app.js', 'rb') as f:
|
| 3 |
-
content = f.read()
|
| 4 |
-
text = content.decode('utf-8', errors='ignore')
|
| 5 |
-
text = text.replace('\x00', '')
|
| 6 |
-
with open('static/app.js', 'w', encoding='utf-8') as f:
|
| 7 |
-
f.write(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_headers.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
| 1 |
-
content = open('static/app.js', 'rb').read().decode('utf-8')
|
| 2 |
-
|
| 3 |
-
# Find and replace getHeaders
|
| 4 |
-
idx = content.find('const getHeaders = ()')
|
| 5 |
-
end_idx = content.find('};', idx) + 2
|
| 6 |
-
|
| 7 |
-
old_block = content[idx:end_idx]
|
| 8 |
-
print('Found block:')
|
| 9 |
-
print(repr(old_block[:200]))
|
| 10 |
-
|
| 11 |
-
new_block = """const getHeaders = () => {
|
| 12 |
-
// 'accessKey' is stored at login (camelCase) - fixed from snake_case mismatch
|
| 13 |
-
const access_key = sessionStorage.getItem('accessKey') || localStorage.getItem('accessKey') || '';
|
| 14 |
-
return {
|
| 15 |
-
'Content-Type': 'application/json',
|
| 16 |
-
'X-Access-Key': access_key
|
| 17 |
-
};
|
| 18 |
-
};"""
|
| 19 |
-
|
| 20 |
-
content = content[:idx] + new_block + content[end_idx:]
|
| 21 |
-
open('static/app.js', 'w', encoding='utf-8').write(content)
|
| 22 |
-
print('Done! Fixed getHeaders key name mismatch.')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fix_math.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
# Fix models.py (model_capm)
|
| 4 |
+
models_code = open('models.py', 'r', encoding='utf-8').read()
|
| 5 |
+
|
| 6 |
+
capm_fix = '''
|
| 7 |
+
MIN_OBS = 30
|
| 8 |
+
if len(returns_df) < MIN_OBS:
|
| 9 |
+
betas = pd.Series(1.0, index=returns_df.columns)
|
| 10 |
+
else:
|
| 11 |
+
cov = returns_df.apply(lambda x: x.cov(benchmark_rets))
|
| 12 |
+
var_m = benchmark_rets.var()
|
| 13 |
+
betas = cov / var_m if var_m > 0 else pd.Series(1.0, index=returns_df.columns)
|
| 14 |
+
'''
|
| 15 |
+
|
| 16 |
+
# Replace the CAPM logic
|
| 17 |
+
if 'MIN_OBS = 30' not in models_code:
|
| 18 |
+
models_code = re.sub(
|
| 19 |
+
r'cov = returns_df\.apply\(lambda x: x\.cov\(benchmark_rets\)\)\s*var_m = benchmark_rets\.var\(\)\s*betas = cov / var_m if var_m > 0 else pd\.Series\(1\.0, index=returns_df\.columns\)',
|
| 20 |
+
capm_fix.strip(),
|
| 21 |
+
models_code
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
with open('models.py', 'w', encoding='utf-8') as f:
|
| 25 |
+
f.write(models_code)
|
| 26 |
+
|
| 27 |
+
# Fix forecast_generation.py (FinBERT blending)
|
| 28 |
+
forecast_code = open('forecast_generation.py', 'r', encoding='utf-8').read()
|
| 29 |
+
|
| 30 |
+
finbert_fix_old = ''' sent_uncert = pd.Series(10.0, index=ctx.exp_ret_df.columns)
|
| 31 |
+
has_active_sentiment = False
|
| 32 |
+
for t, data in ai_sentiment.items():
|
| 33 |
+
s = data.get('sentiment', 0.0)
|
| 34 |
+
if abs(s) > 0.5:
|
| 35 |
+
sent_rets[t] = pi_forecast.expected_returns.get(t, 0.0) + (s * 0.05)
|
| 36 |
+
sent_uncert[t] = 0.5
|
| 37 |
+
has_active_sentiment = True'''
|
| 38 |
+
|
| 39 |
+
finbert_fix_new = ''' # Use historical variance of the asset as baseline uncertainty, or a high prior (2.0)
|
| 40 |
+
base_uncert = ctx.exp_ret_df.var() * 252
|
| 41 |
+
base_uncert = base_uncert.replace(0, 2.0).fillna(2.0)
|
| 42 |
+
sent_uncert = base_uncert.copy()
|
| 43 |
+
has_active_sentiment = False
|
| 44 |
+
for t, data in ai_sentiment.items():
|
| 45 |
+
s = data.get('sentiment', 0.0)
|
| 46 |
+
# Dynamic confidence: stronger signal = lower uncertainty (scaled between 0.1 and 1.0)
|
| 47 |
+
confidence_scaler = max(0.1, 1.0 - abs(s))
|
| 48 |
+
if abs(s) > 0.2: # lower the threshold to accept more signals
|
| 49 |
+
sent_rets[t] = pi_forecast.expected_returns.get(t, 0.0) + (s * 0.05)
|
| 50 |
+
sent_uncert[t] = sent_uncert[t] * confidence_scaler
|
| 51 |
+
has_active_sentiment = True'''
|
| 52 |
+
|
| 53 |
+
if 'confidence_scaler' not in forecast_code:
|
| 54 |
+
forecast_code = forecast_code.replace(finbert_fix_old, finbert_fix_new)
|
| 55 |
+
|
| 56 |
+
with open('forecast_generation.py', 'w', encoding='utf-8') as f:
|
| 57 |
+
f.write(forecast_code)
|
| 58 |
+
|
| 59 |
+
print("Fixed math bugs in models.py and forecast_generation.py")
|
forecast_generation.py
CHANGED
|
@@ -20,6 +20,7 @@ from models import (
|
|
| 20 |
model_spo_plus
|
| 21 |
)
|
| 22 |
from abc import ABC, abstractmethod
|
|
|
|
| 23 |
|
| 24 |
try:
|
| 25 |
from fixed_income import bond_risk_metrics
|
|
@@ -32,11 +33,129 @@ try:
|
|
| 32 |
except ImportError:
|
| 33 |
raise SystemExit(f"\n{Color.RED}β 'bl_bridge.py' is missing. Cannot integrate ML models with BL prior.{Color.RESET}")
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
|
|
|
|
|
|
|
|
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
def _generate_forecasts(returns_df, yield_df, benchmark_rets, risk_input, risk_factor, cfg, model, ff_df, spread_map, macro, silent, opt_rets_df, opt_spy_rets, opt_ff_df, opt_params=None):
|
| 42 |
"""Generates Risk and Expected Return matrices, routing HMM regime states and fixed income logic."""
|
|
@@ -117,135 +236,29 @@ def _generate_forecasts(returns_df, yield_df, benchmark_rets, risk_input, risk_f
|
|
| 117 |
cov_mat = cov_res_obj.covariance
|
| 118 |
|
| 119 |
vol = cov_res_obj.volatility
|
| 120 |
-
# FIX (Bug 3): Use frequency-routed exp_ret_df for correlation display so monthly mode
|
| 121 |
-
# does not show daily correlation structure on a monthly-optimised portfolio.
|
| 122 |
corr_matrix = exp_ret_df.corr()
|
| 123 |
|
| 124 |
-
# ββ 4. Strategy Pattern for Return Forecasts ββ
|
| 125 |
-
class ForecastStrategy(ABC):
|
| 126 |
-
@abstractmethod
|
| 127 |
-
def generate(self) -> ModelReturnForecast:
|
| 128 |
-
pass
|
| 129 |
-
|
| 130 |
-
class CapmStrategy(ForecastStrategy):
|
| 131 |
-
def generate(self):
|
| 132 |
-
return model_capm(exp_ret_df, exp_bench, rfr, periods=periods, silent=(model != 1 or silent))
|
| 133 |
-
|
| 134 |
-
class BlackLittermanStrategy(ForecastStrategy):
|
| 135 |
-
def generate(self):
|
| 136 |
-
return model_black_litterman(exp_ret_df, cov_mat, rfr, cfg)
|
| 137 |
-
|
| 138 |
-
class BayesianBlendStrategy(ForecastStrategy):
|
| 139 |
-
def generate(self):
|
| 140 |
-
return model_bayesian_blend(exp_ret_df, cov_mat, capm_rets.expected_returns, ew_hist_rets=hist_rets, periods=periods)
|
| 141 |
-
|
| 142 |
-
class FamaFrenchStrategy(ForecastStrategy):
|
| 143 |
-
def generate(self):
|
| 144 |
-
return model_fama_french(exp_ret_df, exp_bench, exp_ff, rfr, cfg, periods=periods, regime_severity=regime_severity)
|
| 145 |
-
|
| 146 |
-
class EnsembleStrategy(ForecastStrategy):
|
| 147 |
-
def generate(self):
|
| 148 |
-
from data import build_ml_features
|
| 149 |
-
import alternative_data
|
| 150 |
-
|
| 151 |
-
tickers = list(returns_df.columns)
|
| 152 |
-
alt_data = alternative_data.fetch_options_sentiment(tickers, silent=silent)
|
| 153 |
-
|
| 154 |
-
features_dict = build_ml_features(returns_df, exp_bench.reindex(returns_df.index).fillna(0), exp_ff, alt_data=alt_data)
|
| 155 |
-
|
| 156 |
-
ml_forecast = ensemble_return_forecast(features_dict, rfr, capm_rets.expected_returns, silent=silent)
|
| 157 |
-
pi_forecast = model_black_litterman(exp_ret_df, cov_mat, rfr, cfg)
|
| 158 |
-
scaled_ml_uncert = scale_uncertainty_by_regime(ml_forecast.uncertainties, regime_severity)
|
| 159 |
-
|
| 160 |
-
view_sets = [(ml_forecast.expected_returns, scaled_ml_uncert)]
|
| 161 |
-
|
| 162 |
-
if cfg.get('anova_enabled', False):
|
| 163 |
-
anova_forecast = model_bsts(exp_ret_df, periods=periods, silent=silent)
|
| 164 |
-
if anova_forecast.expected_returns is not None and anova_forecast.uncertainties is not None:
|
| 165 |
-
view_sets.append((anova_forecast.expected_returns, anova_forecast.uncertainties))
|
| 166 |
-
|
| 167 |
-
import os
|
| 168 |
-
hf_token = os.getenv("HF_TOKEN")
|
| 169 |
-
if hf_token and not cfg.get('_is_historical_backtest', False):
|
| 170 |
-
ai_sentiment = alternative_data.fetch_ai_news_sentiment(tickers, hf_token, silent=silent)
|
| 171 |
-
sent_rets = pd.Series(0.0, index=exp_ret_df.columns)
|
| 172 |
-
sent_uncert = pd.Series(10.0, index=exp_ret_df.columns)
|
| 173 |
-
has_active_sentiment = False
|
| 174 |
-
for t, data in ai_sentiment.items():
|
| 175 |
-
s = data.get('sentiment', 0.0)
|
| 176 |
-
if abs(s) > 0.5:
|
| 177 |
-
# Shock equilibrium by up to +/- 5% with high uncertainty
|
| 178 |
-
sent_rets[t] = pi_forecast.expected_returns.get(t, 0.0) + (s * 0.05)
|
| 179 |
-
sent_uncert[t] = 0.5 # High uncertainty
|
| 180 |
-
has_active_sentiment = True
|
| 181 |
-
if has_active_sentiment:
|
| 182 |
-
view_sets.append((sent_rets, sent_uncert))
|
| 183 |
-
|
| 184 |
-
dynamic_tau = max(0.005, 0.05 / regime_severity)
|
| 185 |
-
bl_post_rets = compute_bl_posterior(pi_forecast.expected_returns, view_sets, cov_mat, tau=dynamic_tau, silent=silent)
|
| 186 |
-
|
| 187 |
-
capm_forecast = model_capm(exp_ret_df, exp_bench, rfr, periods=periods, silent=True)
|
| 188 |
-
return ModelReturnForecast(
|
| 189 |
-
expected_returns=bl_post_rets,
|
| 190 |
-
betas=capm_forecast.betas,
|
| 191 |
-
feature_importances=ml_forecast.feature_importances,
|
| 192 |
-
ai_sentiment=ai_sentiment if 'ai_sentiment' in locals() else None
|
| 193 |
-
)
|
| 194 |
-
|
| 195 |
-
class DifferentiableOptimizationStrategy(ForecastStrategy):
|
| 196 |
-
def generate(self):
|
| 197 |
-
if cfg.get('_is_historical_backtest', False):
|
| 198 |
-
return model_bayesian_blend(exp_ret_df, cov_mat, capm_rets.expected_returns, ew_hist_rets=hist_rets, periods=periods)
|
| 199 |
-
return model_spo_plus(exp_ret_df, exp_bench, exp_ff, cov_mat, rfr, cfg, periods=periods, silent=silent)
|
| 200 |
-
|
| 201 |
-
class RegimeAdaptiveStrategy(ForecastStrategy):
|
| 202 |
-
"""
|
| 203 |
-
Regime-Adaptive Factor Blend (formerly World Model slot).
|
| 204 |
-
|
| 205 |
-
Uses the HMM regime severity score to dynamically mix CAPM,
|
| 206 |
-
Black-Litterman, and Bayesian shrinkage models. In calm regimes,
|
| 207 |
-
allows more weight on data-driven signals (Bayesian). In crash
|
| 208 |
-
regimes, anchors heavily to the market-equilibrium BL prior.
|
| 209 |
-
"""
|
| 210 |
-
def generate(self):
|
| 211 |
-
# 1. Generate component forecasts
|
| 212 |
-
capm_forecast = model_capm(exp_ret_df, exp_bench, rfr, periods=periods, silent=True)
|
| 213 |
-
bl_forecast = model_black_litterman(exp_ret_df, cov_mat, rfr, cfg)
|
| 214 |
-
bayes_forecast = model_bayesian_blend(exp_ret_df, cov_mat, capm_forecast.expected_returns, ew_hist_rets=hist_rets, periods=periods)
|
| 215 |
-
|
| 216 |
-
# 2. Regime-dependent mixing weights
|
| 217 |
-
# severity_score: 1.0 = calm, 2.0+ = stressed, 3.0+ = crash
|
| 218 |
-
sev = regime_severity
|
| 219 |
-
|
| 220 |
-
# Smooth sigmoid transition from Calm (sev=1) to Crash (sev=3)
|
| 221 |
-
# Center at sev=2.0
|
| 222 |
-
z = (sev - 2.0) * 2.0
|
| 223 |
-
stress_weight = 1.0 / (1.0 + math.exp(-z))
|
| 224 |
-
|
| 225 |
-
# Calm targets: CAPM=0.25, BL=0.30, Bayes=0.45
|
| 226 |
-
# Crash targets: CAPM=0.15, BL=0.70, Bayes=0.15
|
| 227 |
-
w_capm = 0.25 * (1.0 - stress_weight) + 0.15 * stress_weight
|
| 228 |
-
w_bl = 0.30 * (1.0 - stress_weight) + 0.70 * stress_weight
|
| 229 |
-
w_bayes = 0.45 * (1.0 - stress_weight) + 0.15 * stress_weight
|
| 230 |
-
|
| 231 |
-
blended_rets = (
|
| 232 |
-
w_capm * capm_forecast.expected_returns +
|
| 233 |
-
w_bl * bl_forecast.expected_returns +
|
| 234 |
-
w_bayes * bayes_forecast.expected_returns
|
| 235 |
-
)
|
| 236 |
-
|
| 237 |
-
if not silent:
|
| 238 |
-
print(f" {Color.DIM}[INFO] Regime-Adaptive Blend: CAPM={w_capm:.0%} BL={w_bl:.0%} Bayes={w_bayes:.0%} (severity={sev:.2f}){Color.RESET}")
|
| 239 |
-
|
| 240 |
-
return ModelReturnForecast(
|
| 241 |
-
expected_returns=blended_rets,
|
| 242 |
-
betas=capm_forecast.betas,
|
| 243 |
-
alpha=bayes_forecast.alpha
|
| 244 |
-
)
|
| 245 |
-
|
| 246 |
# Base Expected Return Calculation (Fallback)
|
| 247 |
capm_rets = model_capm(exp_ret_df, exp_bench, rfr, periods=periods, silent=(model != 1 or silent))
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
strategies = {
|
| 250 |
1: CapmStrategy(),
|
| 251 |
2: BlackLittermanStrategy(),
|
|
@@ -257,13 +270,14 @@ def _generate_forecasts(returns_df, yield_df, benchmark_rets, risk_input, risk_f
|
|
| 257 |
}
|
| 258 |
|
| 259 |
strategy = strategies.get(model, CapmStrategy())
|
| 260 |
-
forecast_model = strategy.generate()
|
| 261 |
|
| 262 |
exp_rets = forecast_model.expected_returns
|
| 263 |
betas = getattr(forecast_model, 'betas', None)
|
| 264 |
if betas is None:
|
| 265 |
betas = capm_rets.betas
|
| 266 |
ff_betas = getattr(forecast_model, 'factor_exposures', None)
|
|
|
|
| 267 |
js_alpha = getattr(forecast_model, 'alpha', 0.0)
|
| 268 |
feature_importances = getattr(forecast_model, 'feature_importances', None)
|
| 269 |
|
|
|
|
| 20 |
model_spo_plus
|
| 21 |
)
|
| 22 |
from abc import ABC, abstractmethod
|
| 23 |
+
from dataclasses import dataclass
|
| 24 |
|
| 25 |
try:
|
| 26 |
from fixed_income import bond_risk_metrics
|
|
|
|
| 33 |
except ImportError:
|
| 34 |
raise SystemExit(f"\n{Color.RED}β 'bl_bridge.py' is missing. Cannot integrate ML models with BL prior.{Color.RESET}")
|
| 35 |
|
| 36 |
+
@dataclass
|
| 37 |
+
class ForecastContext:
|
| 38 |
+
exp_ret_df: pd.DataFrame
|
| 39 |
+
exp_bench: pd.Series
|
| 40 |
+
rfr: float
|
| 41 |
+
periods: int
|
| 42 |
+
model: int
|
| 43 |
+
silent: bool
|
| 44 |
+
cov_mat: pd.DataFrame
|
| 45 |
+
cfg: dict
|
| 46 |
+
capm_rets_expected: pd.Series
|
| 47 |
+
hist_rets: pd.Series
|
| 48 |
+
exp_ff: pd.DataFrame
|
| 49 |
+
regime_severity: float
|
| 50 |
+
returns_df: pd.DataFrame
|
| 51 |
|
| 52 |
+
class ForecastStrategy(ABC):
|
| 53 |
+
@abstractmethod
|
| 54 |
+
def generate(self, ctx: ForecastContext) -> ModelReturnForecast:
|
| 55 |
+
pass
|
| 56 |
|
| 57 |
+
class CapmStrategy(ForecastStrategy):
|
| 58 |
+
def generate(self, ctx: ForecastContext):
|
| 59 |
+
return model_capm(ctx.exp_ret_df, ctx.exp_bench, ctx.rfr, periods=ctx.periods, silent=(ctx.model != 1 or ctx.silent))
|
| 60 |
|
| 61 |
+
class BlackLittermanStrategy(ForecastStrategy):
|
| 62 |
+
def generate(self, ctx: ForecastContext):
|
| 63 |
+
return model_black_litterman(ctx.exp_ret_df, ctx.cov_mat, ctx.rfr, ctx.cfg)
|
| 64 |
|
| 65 |
+
class BayesianBlendStrategy(ForecastStrategy):
|
| 66 |
+
def generate(self, ctx: ForecastContext):
|
| 67 |
+
return model_bayesian_blend(ctx.exp_ret_df, ctx.cov_mat, ctx.capm_rets_expected, ew_hist_rets=ctx.hist_rets, periods=ctx.periods)
|
| 68 |
|
| 69 |
+
class FamaFrenchStrategy(ForecastStrategy):
|
| 70 |
+
def generate(self, ctx: ForecastContext):
|
| 71 |
+
return model_fama_french(ctx.exp_ret_df, ctx.exp_bench, ctx.exp_ff, ctx.rfr, ctx.cfg, periods=ctx.periods, regime_severity=ctx.regime_severity)
|
| 72 |
+
|
| 73 |
+
class EnsembleStrategy(ForecastStrategy):
|
| 74 |
+
def generate(self, ctx: ForecastContext):
|
| 75 |
+
from data import build_ml_features
|
| 76 |
+
import alternative_data
|
| 77 |
+
|
| 78 |
+
tickers = list(ctx.returns_df.columns)
|
| 79 |
+
|
| 80 |
+
features_dict = build_ml_features(ctx.returns_df, ctx.exp_bench.reindex(ctx.returns_df.index).fillna(0), ctx.exp_ff, alt_data=None)
|
| 81 |
+
|
| 82 |
+
ml_forecast = ensemble_return_forecast(features_dict, ctx.rfr, ctx.capm_rets_expected, silent=ctx.silent)
|
| 83 |
+
pi_forecast = model_black_litterman(ctx.exp_ret_df, ctx.cov_mat, ctx.rfr, ctx.cfg)
|
| 84 |
+
scaled_ml_uncert = scale_uncertainty_by_regime(ml_forecast.uncertainties, ctx.regime_severity)
|
| 85 |
+
|
| 86 |
+
view_sets = [(ml_forecast.expected_returns, scaled_ml_uncert)]
|
| 87 |
+
|
| 88 |
+
if ctx.cfg.get('anova_enabled', False):
|
| 89 |
+
anova_forecast = model_bsts(ctx.exp_ret_df, periods=ctx.periods, silent=ctx.silent)
|
| 90 |
+
if anova_forecast.expected_returns is not None and anova_forecast.uncertainties is not None:
|
| 91 |
+
view_sets.append((anova_forecast.expected_returns, anova_forecast.uncertainties))
|
| 92 |
+
|
| 93 |
+
import os
|
| 94 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 95 |
+
if hf_token and not ctx.cfg.get('_is_historical_backtest', False):
|
| 96 |
+
ai_sentiment = alternative_data.fetch_ai_news_sentiment(tickers, hf_token, silent=ctx.silent)
|
| 97 |
+
sent_rets = pd.Series(0.0, index=ctx.exp_ret_df.columns)
|
| 98 |
+
# Use historical variance of the asset as baseline uncertainty, or a high prior (2.0)
|
| 99 |
+
base_uncert = ctx.exp_ret_df.var() * 252
|
| 100 |
+
base_uncert = base_uncert.replace(0, 2.0).fillna(2.0)
|
| 101 |
+
sent_uncert = base_uncert.copy()
|
| 102 |
+
has_active_sentiment = False
|
| 103 |
+
for t, data in ai_sentiment.items():
|
| 104 |
+
s = data.get('sentiment', 0.0)
|
| 105 |
+
# Dynamic confidence: stronger signal = lower uncertainty (scaled between 0.1 and 1.0)
|
| 106 |
+
confidence_scaler = max(0.1, 1.0 - abs(s))
|
| 107 |
+
if abs(s) > 0.2: # lower the threshold to accept more signals
|
| 108 |
+
sent_rets[t] = pi_forecast.expected_returns.get(t, 0.0) + (s * 0.05)
|
| 109 |
+
sent_uncert[t] = sent_uncert[t] * confidence_scaler
|
| 110 |
+
has_active_sentiment = True
|
| 111 |
+
if has_active_sentiment:
|
| 112 |
+
view_sets.append((sent_rets, sent_uncert))
|
| 113 |
+
|
| 114 |
+
dynamic_tau = max(0.005, 0.05 / ctx.regime_severity)
|
| 115 |
+
bl_post_rets = compute_bl_posterior(pi_forecast.expected_returns, view_sets, ctx.cov_mat, tau=dynamic_tau, silent=ctx.silent)
|
| 116 |
+
|
| 117 |
+
capm_forecast = model_capm(ctx.exp_ret_df, ctx.exp_bench, ctx.rfr, periods=ctx.periods, silent=True)
|
| 118 |
+
return ModelReturnForecast(
|
| 119 |
+
expected_returns=bl_post_rets,
|
| 120 |
+
betas=capm_forecast.betas,
|
| 121 |
+
feature_importances=ml_forecast.feature_importances,
|
| 122 |
+
ai_sentiment=ai_sentiment if 'ai_sentiment' in locals() else None
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
class DifferentiableOptimizationStrategy(ForecastStrategy):
|
| 126 |
+
def generate(self, ctx: ForecastContext):
|
| 127 |
+
if ctx.cfg.get('_is_historical_backtest', False):
|
| 128 |
+
return model_bayesian_blend(ctx.exp_ret_df, ctx.cov_mat, ctx.capm_rets_expected, ew_hist_rets=ctx.hist_rets, periods=ctx.periods)
|
| 129 |
+
return model_spo_plus(ctx.exp_ret_df, ctx.exp_bench, ctx.exp_ff, ctx.cov_mat, ctx.rfr, ctx.cfg, periods=ctx.periods, silent=ctx.silent)
|
| 130 |
+
|
| 131 |
+
class RegimeAdaptiveStrategy(ForecastStrategy):
|
| 132 |
+
def generate(self, ctx: ForecastContext):
|
| 133 |
+
capm_forecast = model_capm(ctx.exp_ret_df, ctx.exp_bench, ctx.rfr, periods=ctx.periods, silent=True)
|
| 134 |
+
bl_forecast = model_black_litterman(ctx.exp_ret_df, ctx.cov_mat, ctx.rfr, ctx.cfg)
|
| 135 |
+
bayes_forecast = model_bayesian_blend(ctx.exp_ret_df, ctx.cov_mat, capm_forecast.expected_returns, ew_hist_rets=ctx.hist_rets, periods=ctx.periods)
|
| 136 |
+
|
| 137 |
+
sev = ctx.regime_severity
|
| 138 |
+
z = (sev - 2.0) * 2.0
|
| 139 |
+
stress_weight = 1.0 / (1.0 + math.exp(-z))
|
| 140 |
+
|
| 141 |
+
w_capm = 0.25 * (1.0 - stress_weight) + 0.15 * stress_weight
|
| 142 |
+
w_bl = 0.30 * (1.0 - stress_weight) + 0.70 * stress_weight
|
| 143 |
+
w_bayes = 0.45 * (1.0 - stress_weight) + 0.15 * stress_weight
|
| 144 |
+
|
| 145 |
+
blended_rets = (
|
| 146 |
+
w_capm * capm_forecast.expected_returns +
|
| 147 |
+
w_bl * bl_forecast.expected_returns +
|
| 148 |
+
w_bayes * bayes_forecast.expected_returns
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if not ctx.silent:
|
| 152 |
+
print(f" {Color.DIM}[INFO] Regime-Adaptive Blend: CAPM={w_capm:.0%} BL={w_bl:.0%} Bayes={w_bayes:.0%} (severity={sev:.2f}){Color.RESET}")
|
| 153 |
+
|
| 154 |
+
return ModelReturnForecast(
|
| 155 |
+
expected_returns=blended_rets,
|
| 156 |
+
betas=capm_forecast.betas,
|
| 157 |
+
alpha=bayes_forecast.alpha
|
| 158 |
+
)
|
| 159 |
|
| 160 |
def _generate_forecasts(returns_df, yield_df, benchmark_rets, risk_input, risk_factor, cfg, model, ff_df, spread_map, macro, silent, opt_rets_df, opt_spy_rets, opt_ff_df, opt_params=None):
|
| 161 |
"""Generates Risk and Expected Return matrices, routing HMM regime states and fixed income logic."""
|
|
|
|
| 236 |
cov_mat = cov_res_obj.covariance
|
| 237 |
|
| 238 |
vol = cov_res_obj.volatility
|
|
|
|
|
|
|
| 239 |
corr_matrix = exp_ret_df.corr()
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
# Base Expected Return Calculation (Fallback)
|
| 242 |
capm_rets = model_capm(exp_ret_df, exp_bench, rfr, periods=periods, silent=(model != 1 or silent))
|
| 243 |
|
| 244 |
+
from constraints import make_nearest_psd # Make sure it's available
|
| 245 |
+
|
| 246 |
+
ctx = ForecastContext(
|
| 247 |
+
exp_ret_df=exp_ret_df,
|
| 248 |
+
exp_bench=exp_bench,
|
| 249 |
+
rfr=rfr,
|
| 250 |
+
periods=periods,
|
| 251 |
+
model=model,
|
| 252 |
+
silent=silent,
|
| 253 |
+
cov_mat=cov_mat,
|
| 254 |
+
cfg=cfg,
|
| 255 |
+
capm_rets_expected=capm_rets.expected_returns,
|
| 256 |
+
hist_rets=hist_rets,
|
| 257 |
+
exp_ff=exp_ff,
|
| 258 |
+
regime_severity=regime_severity,
|
| 259 |
+
returns_df=returns_df
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
strategies = {
|
| 263 |
1: CapmStrategy(),
|
| 264 |
2: BlackLittermanStrategy(),
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
strategy = strategies.get(model, CapmStrategy())
|
| 273 |
+
forecast_model = strategy.generate(ctx)
|
| 274 |
|
| 275 |
exp_rets = forecast_model.expected_returns
|
| 276 |
betas = getattr(forecast_model, 'betas', None)
|
| 277 |
if betas is None:
|
| 278 |
betas = capm_rets.betas
|
| 279 |
ff_betas = getattr(forecast_model, 'factor_exposures', None)
|
| 280 |
+
|
| 281 |
js_alpha = getattr(forecast_model, 'alpha', 0.0)
|
| 282 |
feature_importances = getattr(forecast_model, 'feature_importances', None)
|
| 283 |
|
models.py
CHANGED
|
@@ -603,9 +603,13 @@ def model_capm(returns_df, benchmark_rets, rfr, periods=252, silent=False):
|
|
| 603 |
Returns:
|
| 604 |
tuple: (Expected returns pd.Series, Market betas pd.Series)
|
| 605 |
"""
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
|
| 610 |
capm_rets = rfr + (betas * get_conditional_erp(rfr))
|
| 611 |
return ModelReturnForecast(expected_returns=capm_rets, betas=betas)
|
|
@@ -796,81 +800,6 @@ def model_bsts(returns_df, periods=12, silent=False):
|
|
| 796 |
except ImportError:
|
| 797 |
return ModelReturnForecast(expected_returns=pd.Series(dtype=float))
|
| 798 |
|
| 799 |
-
def model_spo_plus(returns_df, benchmark_rets, ff_df, cov_mat, rfr, cfg, periods=252, silent=False):
|
| 800 |
-
"""
|
| 801 |
-
End-to-End Differentiable Optimization (SPO+ surrogate).
|
| 802 |
-
Since true SPO+ requires cvxpylayers (which is heavy), this implements a
|
| 803 |
-
differentiable surrogate loss that penalizes predictions based on the
|
| 804 |
-
covariance matrix to approximate the regret of the downstream optimizer.
|
| 805 |
-
"""
|
| 806 |
-
if not silent:
|
| 807 |
-
print(f" {Color.DIM}[INFO] Training End-to-End SPO+ Model (Differentiable Optimization)...{Color.RESET}", flush=True)
|
| 808 |
-
|
| 809 |
-
try:
|
| 810 |
-
import torch
|
| 811 |
-
import torch.nn as nn
|
| 812 |
-
import torch.optim as optim
|
| 813 |
-
except ImportError:
|
| 814 |
-
if not silent:
|
| 815 |
-
print(f" {Color.YELLOW}β PyTorch not installed. Falling back to XGBoost Ensemble for SPO+.{Color.RESET}")
|
| 816 |
-
from data import build_ml_features
|
| 817 |
-
features_dict = build_ml_features(returns_df, benchmark_rets, ff_df)
|
| 818 |
-
return ensemble_return_forecast(features_dict, rfr, returns_df.mean() * periods, silent=silent)
|
| 819 |
-
|
| 820 |
-
# Convert data
|
| 821 |
-
T, N = returns_df.shape
|
| 822 |
-
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 823 |
-
|
| 824 |
-
Y = torch.tensor(returns_df.values, dtype=torch.float32, device=device)
|
| 825 |
-
# Simple feature: trailing 5-day return
|
| 826 |
-
X = torch.zeros((T, N), dtype=torch.float32, device=device)
|
| 827 |
-
for i in range(5, T):
|
| 828 |
-
X[i] = torch.mean(Y[i-5:i], dim=0)
|
| 829 |
-
|
| 830 |
-
Sigma = torch.tensor(cov_mat.values, dtype=torch.float32, device=device)
|
| 831 |
-
# Add small ridge to Sigma
|
| 832 |
-
Sigma += torch.eye(N, device=device) * 1e-4
|
| 833 |
-
Sigma_inv = torch.linalg.inv(Sigma)
|
| 834 |
-
|
| 835 |
-
# Model
|
| 836 |
-
class SPOModel(nn.Module):
|
| 837 |
-
def __init__(self, n_assets):
|
| 838 |
-
super().__init__()
|
| 839 |
-
self.linear = nn.Linear(n_assets, n_assets)
|
| 840 |
-
|
| 841 |
-
def forward(self, x):
|
| 842 |
-
return self.linear(x)
|
| 843 |
-
|
| 844 |
-
model = SPOModel(N).to(device)
|
| 845 |
-
optimizer = optim.Adam(model.parameters(), lr=0.01)
|
| 846 |
-
|
| 847 |
-
# Training Loop
|
| 848 |
-
model.train()
|
| 849 |
-
epochs = 100
|
| 850 |
-
for epoch in range(epochs):
|
| 851 |
-
optimizer.zero_grad()
|
| 852 |
-
pred = model(X) # (T, N)
|
| 853 |
-
|
| 854 |
-
# SPO+ surrogate loss: minimize expected regret
|
| 855 |
-
# Loss = (Pred - Target)^T * Sigma^-1 * (Pred - Target)
|
| 856 |
-
# This penalizes errors in directions of low variance more heavily
|
| 857 |
-
diff = pred - Y
|
| 858 |
-
# Compute quadratic form efficiently
|
| 859 |
-
loss = torch.mean(torch.sum(torch.matmul(diff, Sigma_inv) * diff, dim=1))
|
| 860 |
-
|
| 861 |
-
loss.backward()
|
| 862 |
-
optimizer.step()
|
| 863 |
-
|
| 864 |
-
model.eval()
|
| 865 |
-
with torch.no_grad():
|
| 866 |
-
# Predict next period using last 5 days
|
| 867 |
-
last_x = torch.mean(Y[-5:], dim=0).unsqueeze(0)
|
| 868 |
-
final_pred = model(last_x).squeeze().cpu().numpy() * periods
|
| 869 |
-
|
| 870 |
-
# Combine with CAPM betas
|
| 871 |
-
capm = model_capm(returns_df, benchmark_rets, rfr, periods, silent=True)
|
| 872 |
-
return ModelReturnForecast(expected_returns=pd.Series(final_pred, index=returns_df.columns), betas=capm.betas)
|
| 873 |
-
|
| 874 |
exp_rets = {}
|
| 875 |
uncertainties = {}
|
| 876 |
|
|
@@ -951,6 +880,82 @@ def model_spo_plus(returns_df, benchmark_rets, ff_df, cov_mat, rfr, cfg, periods
|
|
| 951 |
|
| 952 |
return ModelReturnForecast(expected_returns=pd.Series(exp_rets), uncertainties=pd.Series(uncertainties))
|
| 953 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
|
| 955 |
|
| 956 |
|
|
|
|
| 603 |
Returns:
|
| 604 |
tuple: (Expected returns pd.Series, Market betas pd.Series)
|
| 605 |
"""
|
| 606 |
+
MIN_OBS = 30
|
| 607 |
+
if len(returns_df) < MIN_OBS:
|
| 608 |
+
betas = pd.Series(1.0, index=returns_df.columns)
|
| 609 |
+
else:
|
| 610 |
+
cov = returns_df.apply(lambda x: x.cov(benchmark_rets))
|
| 611 |
+
var_m = benchmark_rets.var()
|
| 612 |
+
betas = cov / var_m if var_m > 0 else pd.Series(1.0, index=returns_df.columns)
|
| 613 |
|
| 614 |
capm_rets = rfr + (betas * get_conditional_erp(rfr))
|
| 615 |
return ModelReturnForecast(expected_returns=capm_rets, betas=betas)
|
|
|
|
| 800 |
except ImportError:
|
| 801 |
return ModelReturnForecast(expected_returns=pd.Series(dtype=float))
|
| 802 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
exp_rets = {}
|
| 804 |
uncertainties = {}
|
| 805 |
|
|
|
|
| 880 |
|
| 881 |
return ModelReturnForecast(expected_returns=pd.Series(exp_rets), uncertainties=pd.Series(uncertainties))
|
| 882 |
|
| 883 |
+
def model_spo_plus(returns_df, benchmark_rets, ff_df, cov_mat, rfr, cfg, periods=252, silent=False):
|
| 884 |
+
"""
|
| 885 |
+
End-to-End Differentiable Optimization (SPO+ surrogate).
|
| 886 |
+
Since true SPO+ requires cvxpylayers (which is heavy), this implements a
|
| 887 |
+
differentiable surrogate loss that penalizes predictions based on the
|
| 888 |
+
covariance matrix to approximate the regret of the downstream optimizer.
|
| 889 |
+
"""
|
| 890 |
+
if not silent:
|
| 891 |
+
print(f" {Color.DIM}[INFO] Training End-to-End SPO+ Model (Differentiable Optimization)...{Color.RESET}", flush=True)
|
| 892 |
+
|
| 893 |
+
try:
|
| 894 |
+
import torch
|
| 895 |
+
import torch.nn as nn
|
| 896 |
+
import torch.optim as optim
|
| 897 |
+
except ImportError:
|
| 898 |
+
if not silent:
|
| 899 |
+
print(f" {Color.YELLOW}β PyTorch not installed. Falling back to XGBoost Ensemble for SPO+.{Color.RESET}")
|
| 900 |
+
from data import build_ml_features
|
| 901 |
+
features_dict = build_ml_features(returns_df, benchmark_rets, ff_df)
|
| 902 |
+
return ensemble_return_forecast(features_dict, rfr, returns_df.mean() * periods, silent=silent)
|
| 903 |
+
|
| 904 |
+
# Convert data
|
| 905 |
+
T, N = returns_df.shape
|
| 906 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 907 |
+
|
| 908 |
+
Y = torch.tensor(returns_df.values, dtype=torch.float32, device=device)
|
| 909 |
+
# Simple feature: trailing 5-day return
|
| 910 |
+
X = torch.zeros((T, N), dtype=torch.float32, device=device)
|
| 911 |
+
for i in range(5, T):
|
| 912 |
+
X[i] = torch.mean(Y[i-5:i], dim=0)
|
| 913 |
+
|
| 914 |
+
Sigma = torch.tensor(cov_mat.values, dtype=torch.float32, device=device)
|
| 915 |
+
# Add small ridge to Sigma
|
| 916 |
+
Sigma += torch.eye(N, device=device) * 1e-4
|
| 917 |
+
Sigma_inv = torch.linalg.inv(Sigma)
|
| 918 |
+
|
| 919 |
+
# Model
|
| 920 |
+
class SPOModel(nn.Module):
|
| 921 |
+
def __init__(self, n_assets):
|
| 922 |
+
super().__init__()
|
| 923 |
+
self.linear = nn.Linear(n_assets, n_assets)
|
| 924 |
+
|
| 925 |
+
def forward(self, x):
|
| 926 |
+
return self.linear(x)
|
| 927 |
+
|
| 928 |
+
model = SPOModel(N).to(device)
|
| 929 |
+
optimizer = optim.Adam(model.parameters(), lr=0.01)
|
| 930 |
+
|
| 931 |
+
# Training Loop
|
| 932 |
+
model.train()
|
| 933 |
+
epochs = 100
|
| 934 |
+
for epoch in range(epochs):
|
| 935 |
+
optimizer.zero_grad()
|
| 936 |
+
pred = model(X) # (T, N)
|
| 937 |
+
|
| 938 |
+
# SPO+ surrogate loss: minimize expected regret
|
| 939 |
+
# Loss = (Pred - Target)^T * Sigma^-1 * (Pred - Target)
|
| 940 |
+
# This penalizes errors in directions of low variance more heavily
|
| 941 |
+
diff = pred - Y
|
| 942 |
+
# Compute quadratic form efficiently
|
| 943 |
+
loss = torch.mean(torch.sum(torch.matmul(diff, Sigma_inv) * diff, dim=1))
|
| 944 |
+
|
| 945 |
+
loss.backward()
|
| 946 |
+
optimizer.step()
|
| 947 |
+
|
| 948 |
+
model.eval()
|
| 949 |
+
with torch.no_grad():
|
| 950 |
+
# Predict next period using last 5 days
|
| 951 |
+
last_x = torch.mean(Y[-5:], dim=0).unsqueeze(0)
|
| 952 |
+
final_pred = model(last_x).squeeze().cpu().numpy() * periods
|
| 953 |
+
|
| 954 |
+
# Combine with CAPM betas
|
| 955 |
+
capm = model_capm(returns_df, benchmark_rets, rfr, periods, silent=True)
|
| 956 |
+
return ModelReturnForecast(expected_returns=pd.Series(final_pred, index=returns_df.columns), betas=capm.betas)
|
| 957 |
+
|
| 958 |
+
|
| 959 |
|
| 960 |
|
| 961 |
|
report.py
CHANGED
|
@@ -50,6 +50,8 @@ def generate_html_report(weights, exp_rets, cov_mat, vol, corr_matrix, betas,
|
|
| 50 |
risk_adj=None,
|
| 51 |
dm_results=None,
|
| 52 |
var_results=None,
|
|
|
|
|
|
|
| 53 |
overlay_html="",
|
| 54 |
filename=None):
|
| 55 |
"""
|
|
@@ -86,6 +88,8 @@ def generate_html_report(weights, exp_rets, cov_mat, vol, corr_matrix, betas,
|
|
| 86 |
risk_adj,
|
| 87 |
dm_results,
|
| 88 |
var_results,
|
|
|
|
|
|
|
| 89 |
overlay_html
|
| 90 |
)
|
| 91 |
|
|
|
|
| 50 |
risk_adj=None,
|
| 51 |
dm_results=None,
|
| 52 |
var_results=None,
|
| 53 |
+
psr_results=None,
|
| 54 |
+
dsr_results=None,
|
| 55 |
overlay_html="",
|
| 56 |
filename=None):
|
| 57 |
"""
|
|
|
|
| 88 |
risk_adj,
|
| 89 |
dm_results,
|
| 90 |
var_results,
|
| 91 |
+
psr_results,
|
| 92 |
+
dsr_results,
|
| 93 |
overlay_html
|
| 94 |
)
|
| 95 |
|
report_builders/html_diagnostics.py
CHANGED
|
@@ -45,11 +45,19 @@ def build_constraint_diag_html(model_info):
|
|
| 45 |
)
|
| 46 |
|
| 47 |
if relax_log:
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
_cd_inner += (
|
| 50 |
'<p style="color:#f0883e;font-weight:700;margin:0 0 5px">Constraint Relaxation History</p>'
|
| 51 |
-
'<p style="color:#8b949e;font-size:.8rem;margin:0 0 8px">'
|
| 52 |
-
'Initial constraints were infeasible — the engine auto-relaxed these rules to find a solution:</p>'
|
| 53 |
f'<ul style="padding-left:18px;margin:0">{_rl_items}</ul>'
|
| 54 |
)
|
| 55 |
elif not binding_cons:
|
|
|
|
| 45 |
)
|
| 46 |
|
| 47 |
if relax_log:
|
| 48 |
+
if any("Universe too small" in r for r in relax_log):
|
| 49 |
+
_rl_summary = "The universe was too small to simultaneously satisfy constraints (e.g. beta, sector limits). Solved with maximum flexibility."
|
| 50 |
+
_rl_items = f'<li style="color:#f0883e;margin-bottom:4px">{relax_log[0]}</li>'
|
| 51 |
+
elif len(relax_log) > 3:
|
| 52 |
+
_rl_summary = "Severe constraint conflict detected. The optimizer had to run through multiple relaxation stages (dropping beta, sector, and turnover limits) to find a mathematically viable solution."
|
| 53 |
+
_rl_items = f'<li style="color:#f0883e;margin-bottom:4px">Skipped {len(relax_log)} constraint layers to guarantee a valid portfolio.</li>'
|
| 54 |
+
else:
|
| 55 |
+
_rl_summary = "Initial constraints were infeasible — the engine auto-relaxed these rules to find a solution:"
|
| 56 |
+
_rl_items = "".join(f'<li style="color:#f0883e;margin-bottom:4px">{r}</li>' for r in relax_log)
|
| 57 |
+
|
| 58 |
_cd_inner += (
|
| 59 |
'<p style="color:#f0883e;font-weight:700;margin:0 0 5px">Constraint Relaxation History</p>'
|
| 60 |
+
f'<p style="color:#8b949e;font-size:.8rem;margin:0 0 8px">{_rl_summary}</p>'
|
|
|
|
| 61 |
f'<ul style="padding-left:18px;margin:0">{_rl_items}</ul>'
|
| 62 |
)
|
| 63 |
elif not binding_cons:
|
report_builders/html_risk.py
CHANGED
|
@@ -64,9 +64,9 @@ def build_cvar_garch_html(returns_df, w_risky, cfg, model_info):
|
|
| 64 |
|
| 65 |
return cvar_garch_html
|
| 66 |
|
| 67 |
-
def build_risk_attr_html(mvar_series, cvar_components, factor_ret_attr):
|
| 68 |
risk_attr_html = ""
|
| 69 |
-
_has_risk_attr = any(x is not None for x in [mvar_series, cvar_components, factor_ret_attr])
|
| 70 |
if _has_risk_attr:
|
| 71 |
ra_parts = []
|
| 72 |
if mvar_series is not None and not mvar_series.empty:
|
|
@@ -129,6 +129,43 @@ def build_risk_attr_html(mvar_series, cvar_components, factor_ret_attr):
|
|
| 129 |
f'<table><thead><tr><th>Factor</th><th>Contribution to Return</th><th></th></tr></thead><tbody>{fat_rows}</tbody></table></div>'
|
| 130 |
)
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
if ra_parts:
|
| 133 |
risk_attr_html = '<p class="st">Risk Attribution</p>' + "\n".join(ra_parts)
|
| 134 |
|
|
|
|
| 64 |
|
| 65 |
return cvar_garch_html
|
| 66 |
|
| 67 |
+
def build_risk_attr_html(mvar_series, cvar_components, factor_ret_attr, mcreturn_series=None, feature_importances=None):
|
| 68 |
risk_attr_html = ""
|
| 69 |
+
_has_risk_attr = any(x is not None for x in [mvar_series, cvar_components, factor_ret_attr, mcreturn_series, feature_importances])
|
| 70 |
if _has_risk_attr:
|
| 71 |
ra_parts = []
|
| 72 |
if mvar_series is not None and not mvar_series.empty:
|
|
|
|
| 129 |
f'<table><thead><tr><th>Factor</th><th>Contribution to Return</th><th></th></tr></thead><tbody>{fat_rows}</tbody></table></div>'
|
| 130 |
)
|
| 131 |
|
| 132 |
+
if mcreturn_series is not None and not mcreturn_series.empty:
|
| 133 |
+
mcr_rows = ""
|
| 134 |
+
total_mcr = float(mcreturn_series.abs().sum()) or 1.0
|
| 135 |
+
for ticker, mcr in mcreturn_series.sort_values(ascending=False).items():
|
| 136 |
+
mcr_f = float(mcr)
|
| 137 |
+
mcr_col = "#3fb950" if mcr_f > 0 else "#f85149"
|
| 138 |
+
pct_bar = abs(mcr_f) / total_mcr
|
| 139 |
+
bar_w = round(pct_bar * 80, 1)
|
| 140 |
+
mcr_rows += (
|
| 141 |
+
f"<tr><td><strong>{ticker}</strong></td>"
|
| 142 |
+
f"<td style='color:{mcr_col};font-weight:700'>{mcr_f*100:+.3f}%</td>"
|
| 143 |
+
f"<td><div style='background:{mcr_col};width:{bar_w}px;height:8px;border-radius:2px;opacity:.75'></div></td></tr>\n"
|
| 144 |
+
)
|
| 145 |
+
ra_parts.append(
|
| 146 |
+
'<div class="cc" style="overflow-x:auto"><p style="font-size:.8rem;color:#8b949e;margin-bottom:8px">'
|
| 147 |
+
'<strong>Marginal Contribution to Return (MCR)</strong> <span style="font-weight:400">β weight × expected return</span></p>'
|
| 148 |
+
f'<table><thead><tr><th>Ticker</th><th>MCR</th><th>Relative Size</th></tr></thead><tbody>{mcr_rows}</tbody></table></div>'
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if feature_importances is not None and len(feature_importances) > 0:
|
| 152 |
+
shap_rows = ""
|
| 153 |
+
for ticker, feats in feature_importances.items():
|
| 154 |
+
if not feats: continue
|
| 155 |
+
# Pick top 3 features for compactness
|
| 156 |
+
top_feats = sorted(feats.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
|
| 157 |
+
feat_str = "<br>".join([f"<span style='color: {'#3fb950' if v>0 else '#f85149'}'>{k} ({v:+.1f}%)</span>" for k, v in top_feats])
|
| 158 |
+
shap_rows += (
|
| 159 |
+
f"<tr><td style='vertical-align:top'><strong>{ticker}</strong></td>"
|
| 160 |
+
f"<td style='font-size:.8rem;line-height:1.4'>{feat_str}</td></tr>\n"
|
| 161 |
+
)
|
| 162 |
+
if shap_rows:
|
| 163 |
+
ra_parts.append(
|
| 164 |
+
'<div class="cc" style="overflow-x:auto"><p style="font-size:.8rem;color:#8b949e;margin-bottom:8px">'
|
| 165 |
+
'<strong>Explainable AI (SHAP Values)</strong> <span style="font-weight:400">β top driving features for expected returns</span></p>'
|
| 166 |
+
f'<table><thead><tr><th>Ticker</th><th>Top Driving Features (Impact)</th></tr></thead><tbody>{shap_rows}</tbody></table></div>'
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
if ra_parts:
|
| 170 |
risk_attr_html = '<p class="st">Risk Attribution</p>' + "\n".join(ra_parts)
|
| 171 |
|
report_builders/html_validation.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
-
def build_econometric_validation_html(dm_results, var_results):
|
| 2 |
validation_html = ""
|
| 3 |
-
if dm_results or var_results:
|
| 4 |
val_rows = ""
|
| 5 |
if dm_results:
|
| 6 |
-
dm_pass = dm_results.get('significant', False) and dm_results.get('winner')
|
| 7 |
dm_col = "#3fb950" if dm_pass else "#e3b341"
|
| 8 |
val_rows += (
|
| 9 |
f'<div class="mc"><div class="ml" title="Diebold-Mariano Test: Does the ML model statistically beat a naive historical baseline?">'
|
|
@@ -25,8 +25,73 @@ def build_econometric_validation_html(dm_results, var_results):
|
|
| 25 |
+ (f'<div class="ml" style="margin-top:2px;color:#8b949e;font-size:.72rem">{diag_text}</div>' if diag_text and not var_pass else '')
|
| 26 |
+ '</div>'
|
| 27 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
validation_html = (
|
| 29 |
-
'<p class="st">Econometric Validation
|
|
|
|
| 30 |
f'<div class="mg">{val_rows}</div>'
|
| 31 |
)
|
| 32 |
return validation_html
|
|
|
|
| 1 |
+
def build_econometric_validation_html(dm_results, var_results, psr_results=None, dsr_results=None, pt_results=None, lb_results=None):
|
| 2 |
validation_html = ""
|
| 3 |
+
if dm_results or var_results or psr_results or dsr_results:
|
| 4 |
val_rows = ""
|
| 5 |
if dm_results:
|
| 6 |
+
dm_pass = dm_results.get('significant', False) and dm_results.get('winner') not in ['Naive Mean', 'Model 2']
|
| 7 |
dm_col = "#3fb950" if dm_pass else "#e3b341"
|
| 8 |
val_rows += (
|
| 9 |
f'<div class="mc"><div class="ml" title="Diebold-Mariano Test: Does the ML model statistically beat a naive historical baseline?">'
|
|
|
|
| 25 |
+ (f'<div class="ml" style="margin-top:2px;color:#8b949e;font-size:.72rem">{diag_text}</div>' if diag_text and not var_pass else '')
|
| 26 |
+ '</div>'
|
| 27 |
)
|
| 28 |
+
if psr_results:
|
| 29 |
+
psr_pass = psr_results.get('p_value', 1.0) < 0.05
|
| 30 |
+
psr_col = "#3fb950" if psr_pass else "#e3b341"
|
| 31 |
+
val_rows += (
|
| 32 |
+
f'<div class="mc"><div class="ml" title="Probabilistic Sharpe Ratio: Is the Sharpe ratio statistically significant given non-normal returns?">'
|
| 33 |
+
f'Probabilistic Sharpe (PSR) ⓘ</div>'
|
| 34 |
+
f'<div class="mv" style="color:{psr_col}">{"PASS" if psr_pass else "INCONCLUSIVE"}</div>'
|
| 35 |
+
f'<div class="ml" style="margin-top:4px">p = {psr_results.get("p_value", 1.0):.4f} (Obs: {psr_results.get("observed_sharpe", 0.0):.2f})</div></div>'
|
| 36 |
+
)
|
| 37 |
+
if dsr_results:
|
| 38 |
+
dsr_pass = dsr_results.get('p_value', 1.0) < 0.05
|
| 39 |
+
dsr_col = "#3fb950" if dsr_pass else "#e3b341"
|
| 40 |
+
val_rows += (
|
| 41 |
+
f'<div class="mc"><div class="ml" title="Deflated Sharpe Ratio: Is the strategy robust against multiple testing bias?">'
|
| 42 |
+
f'Deflated Sharpe (DSR) ⓘ</div>'
|
| 43 |
+
f'<div class="mv" style="color:{dsr_col}">{"PASS" if dsr_pass else "INCONCLUSIVE"}</div>'
|
| 44 |
+
f'<div class="ml" style="margin-top:4px">p = {dsr_results.get("p_value", 1.0):.4f}</div></div>'
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
if pt_results:
|
| 49 |
+
pt_pass = pt_results.get('significant', False)
|
| 50 |
+
pt_col = "#3fb950" if pt_pass else "#e3b341"
|
| 51 |
+
val_rows += (
|
| 52 |
+
f'<div class="mc"><div class="ml" title="Pesaran-Timmermann Test: Does the ML model predict market direction better than a coin flip?">'
|
| 53 |
+
f'Directional Accuracy (PT Test) ⓘ</div>'
|
| 54 |
+
f'<div class="mv" style="color:{pt_col}">{"PASS" if pt_pass else "INCONCLUSIVE"}</div>'
|
| 55 |
+
f'<div class="ml" style="margin-top:4px">p = {pt_results.get("p_value", 1.0):.4f}</div></div>'
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
if lb_results:
|
| 59 |
+
lb_pass = not lb_results.get('significant', True) # Null hypothesis is NO autocorrelation. We want p > 0.05
|
| 60 |
+
lb_col = "#3fb950" if lb_pass else "#e3b341"
|
| 61 |
+
val_rows += (
|
| 62 |
+
f'<div class="mc"><div class="ml" title="Ljung-Box Test: Did the GARCH model successfully capture all volatility clustering (no autocorrelation in squared residuals)?">'
|
| 63 |
+
f'GARCH Autocorrelation (Ljung-Box) ⓘ</div>'
|
| 64 |
+
f'<div class="mv" style="color:{lb_col}">{"PASS" if lb_pass else "FAIL"}</div>'
|
| 65 |
+
f'<div class="ml" style="margin-top:4px">p = {lb_results.get("p_value", 0.0):.4f}</div></div>'
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
verdicts = []
|
| 69 |
+
if dm_results:
|
| 70 |
+
if dm_results.get('significant', False) and dm_results.get('winner') not in ['Naive Mean', 'Model 2']:
|
| 71 |
+
verdicts.append("Your model statistically outperforms the naive historical baseline at 95% confidence.")
|
| 72 |
+
else:
|
| 73 |
+
verdicts.append("The model does not show statistically significant outperformance over a naive historical mean.")
|
| 74 |
+
|
| 75 |
+
if var_results:
|
| 76 |
+
if var_results.get('overall_pass', False):
|
| 77 |
+
verdicts.append("Tail risk metrics (VaR) hold up well against real-world volatility clustering.")
|
| 78 |
+
else:
|
| 79 |
+
verdicts.append("VaR breaches cluster significantly; consider widening your rebalance frequency or reducing risk limits.")
|
| 80 |
+
|
| 81 |
+
if psr_results and psr_results.get('p_value', 1.0) < 0.05:
|
| 82 |
+
verdicts.append("The Sharpe ratio is robust even under non-normal return distributions.")
|
| 83 |
+
|
| 84 |
+
verdict_html = ""
|
| 85 |
+
if verdicts:
|
| 86 |
+
verdict_html = (
|
| 87 |
+
f'<div style="background:#161b22;border-left:4px solid #58a6ff;padding:10px 14px;margin-bottom:16px;border-radius:4px;font-size:0.85rem;color:#c9d1d9">'
|
| 88 |
+
f'<strong style="color:#58a6ff">Diagnostic Verdict:</strong> {" ".join(verdicts)}'
|
| 89 |
+
f'</div>'
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
validation_html = (
|
| 93 |
+
'<p class="st">Advanced Econometric Validation</p>'
|
| 94 |
+
f'{verdict_html}'
|
| 95 |
f'<div class="mg">{val_rows}</div>'
|
| 96 |
)
|
| 97 |
return validation_html
|
report_data.py
CHANGED
|
@@ -99,6 +99,7 @@ def prepare_template_variables(weights, exp_rets, cov_mat, vol, corr_matrix, bet
|
|
| 99 |
current_weights, current_stats, risk_input,
|
| 100 |
mvar_series, cvar_components, stressed_vol, factor_exp, factor_ret_attr,
|
| 101 |
regime_info, risk_adj, dm_results, var_results,
|
|
|
|
| 102 |
overlay_html=""):
|
| 103 |
|
| 104 |
# Global Config Extraction
|
|
@@ -186,11 +187,13 @@ def prepare_template_variables(weights, exp_rets, cov_mat, vol, corr_matrix, bet
|
|
| 186 |
data_alerts_html = build_data_alerts_html(tn_ratio, n_fragile)
|
| 187 |
warn_html = build_warnings_html(diags)
|
| 188 |
constraint_diag_html = build_constraint_diag_html(model_info)
|
| 189 |
-
validation_html = build_econometric_validation_html(dm_results, var_results)
|
| 190 |
resiliency_html = build_resiliency_html(sens_report, stress_report)
|
| 191 |
tax_html = build_tax_html(cfg, w_risky, cash_w, rfr, opt_ret_val, tax_meta, curr, model_info, exp_rets)
|
| 192 |
cvar_garch_html = build_cvar_garch_html(returns_df, w_risky, cfg, model_info)
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
rc_html = build_rc_html(model_info, weights, chart_data)
|
| 195 |
|
| 196 |
if chart_data.get("rc_ds"):
|
|
|
|
| 99 |
current_weights, current_stats, risk_input,
|
| 100 |
mvar_series, cvar_components, stressed_vol, factor_exp, factor_ret_attr,
|
| 101 |
regime_info, risk_adj, dm_results, var_results,
|
| 102 |
+
psr_results=None, dsr_results=None,
|
| 103 |
overlay_html=""):
|
| 104 |
|
| 105 |
# Global Config Extraction
|
|
|
|
| 187 |
data_alerts_html = build_data_alerts_html(tn_ratio, n_fragile)
|
| 188 |
warn_html = build_warnings_html(diags)
|
| 189 |
constraint_diag_html = build_constraint_diag_html(model_info)
|
| 190 |
+
validation_html = build_econometric_validation_html(dm_results, var_results, psr_results, dsr_results)
|
| 191 |
resiliency_html = build_resiliency_html(sens_report, stress_report)
|
| 192 |
tax_html = build_tax_html(cfg, w_risky, cash_w, rfr, opt_ret_val, tax_meta, curr, model_info, exp_rets)
|
| 193 |
cvar_garch_html = build_cvar_garch_html(returns_df, w_risky, cfg, model_info)
|
| 194 |
+
mcreturn_series = pd.Series({t: w_risky.get(t, 0.0) * exp_rets.get(t, 0.0) for t in w_risky.index})
|
| 195 |
+
feature_importances = model_info.get("feature_importances")
|
| 196 |
+
risk_attr_html = build_risk_attr_html(mvar_series, cvar_components, factor_ret_attr, mcreturn_series, feature_importances)
|
| 197 |
rc_html = build_rc_html(model_info, weights, chart_data)
|
| 198 |
|
| 199 |
if chart_data.get("rc_ds"):
|
report_html.py
CHANGED
|
@@ -4,7 +4,7 @@ import json
|
|
| 4 |
import html
|
| 5 |
import jinja2
|
| 6 |
|
| 7 |
-
def build_whatif_html(w_risky, exp_rets, cov_mat, rfr, curr="$", jacobian=None):
|
| 8 |
"""
|
| 9 |
Returns an HTML+JS block that lets users interactively shock a single
|
| 10 |
ticker and instantly see the updated Expected Return and Volatility.
|
|
@@ -34,9 +34,20 @@ def build_whatif_html(w_risky, exp_rets, cov_mat, rfr, curr="$", jacobian=None):
|
|
| 34 |
|
| 35 |
# Note: Escape HTML characters in tickers to prevent XSS injection in the DOM
|
| 36 |
options_html = ''.join(f'<option value="{html.escape(t)}">{html.escape(t)}</option>' for t in tickers)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
html_block = f"""
|
| 39 |
<p class="st">What-If Scenario Builder</p>
|
|
|
|
| 40 |
<p style="color:#8b949e;font-size:.74rem;margin:-6px 0 10px">
|
| 41 |
Shock a single asset's expected return by a custom percentage and instantly
|
| 42 |
see how the portfolio metrics change. This uses your current weights β it
|
|
@@ -306,7 +317,8 @@ def render_template(template_vars: dict, template_path: str = None) -> str:
|
|
| 306 |
template_vars["cov_mat"],
|
| 307 |
template_vars["rfr_raw"], # Note: uses raw decimal
|
| 308 |
template_vars.get("curr", "$"),
|
| 309 |
-
template_vars.get("jacobian")
|
|
|
|
| 310 |
)
|
| 311 |
else:
|
| 312 |
template_vars["whatif_html"] = ""
|
|
|
|
| 4 |
import html
|
| 5 |
import jinja2
|
| 6 |
|
| 7 |
+
def build_whatif_html(w_risky, exp_rets, cov_mat, rfr, curr="$", jacobian=None, binding_constraints=None):
|
| 8 |
"""
|
| 9 |
Returns an HTML+JS block that lets users interactively shock a single
|
| 10 |
ticker and instantly see the updated Expected Return and Volatility.
|
|
|
|
| 34 |
|
| 35 |
# Note: Escape HTML characters in tickers to prevent XSS injection in the DOM
|
| 36 |
options_html = ''.join(f'<option value="{html.escape(t)}">{html.escape(t)}</option>' for t in tickers)
|
| 37 |
+
|
| 38 |
+
binding_warn_html = ""
|
| 39 |
+
if binding_constraints:
|
| 40 |
+
binding_warn_html = (
|
| 41 |
+
'<div style="background:#2d1114;border:1px solid #f85149;border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:0.8rem;color:#ff7b72">'
|
| 42 |
+
'<strong>⚠ Binding Constraints Active:</strong> Your base portfolio hit optimizer limits. '
|
| 43 |
+
'The What-If tool uses an unconstrained Jacobian approximation, meaning large shocks might mathematically breach '
|
| 44 |
+
'your original constraints (e.g. leverage or sector caps). Results are illustrative.'
|
| 45 |
+
'</div>'
|
| 46 |
+
)
|
| 47 |
|
| 48 |
html_block = f"""
|
| 49 |
<p class="st">What-If Scenario Builder</p>
|
| 50 |
+
{binding_warn_html}
|
| 51 |
<p style="color:#8b949e;font-size:.74rem;margin:-6px 0 10px">
|
| 52 |
Shock a single asset's expected return by a custom percentage and instantly
|
| 53 |
see how the portfolio metrics change. This uses your current weights β it
|
|
|
|
| 317 |
template_vars["cov_mat"],
|
| 318 |
template_vars["rfr_raw"], # Note: uses raw decimal
|
| 319 |
template_vars.get("curr", "$"),
|
| 320 |
+
template_vars.get("jacobian"),
|
| 321 |
+
template_vars.get("model_info", {}).get("display_constraints", [])
|
| 322 |
)
|
| 323 |
else:
|
| 324 |
template_vars["whatif_html"] = ""
|
report_template.html
CHANGED
|
@@ -261,7 +261,7 @@
|
|
| 261 |
|
| 262 |
<div class="grid">
|
| 263 |
<div class="card">
|
| 264 |
-
<h3>Expected
|
| 265 |
<div class="mg">
|
| 266 |
<div class="mc"><div class="ml">Expected Return</div>{{ret_trans}}</div>
|
| 267 |
<div class="mc"><div class="ml">Volatility (Risk)</div>{{vol_trans}}</div>
|
|
@@ -273,7 +273,7 @@
|
|
| 273 |
</div>
|
| 274 |
|
| 275 |
<div class="card">
|
| 276 |
-
<h3>Historical
|
| 277 |
<div class="mg">
|
| 278 |
<div class="mc"><div class="ml">Ann. Return</div>{{hist_ret_trans}}</div>
|
| 279 |
<div class="mc force-red"><div class="ml" data-tooltip="The largest single drop from peak to trough. Measures worst-case historical loss.">Max Drawdown <span style="color:var(--accent-blue);">[?]</span></div>{{hist_maxdd_trans}}</div>
|
|
|
|
| 261 |
|
| 262 |
<div class="grid">
|
| 263 |
<div class="card">
|
| 264 |
+
<h3>Future Expected Performance</h3>
|
| 265 |
<div class="mg">
|
| 266 |
<div class="mc"><div class="ml">Expected Return</div>{{ret_trans}}</div>
|
| 267 |
<div class="mc"><div class="ml">Volatility (Risk)</div>{{vol_trans}}</div>
|
|
|
|
| 273 |
</div>
|
| 274 |
|
| 275 |
<div class="card">
|
| 276 |
+
<h3>Historical Backtest</h3>
|
| 277 |
<div class="mg">
|
| 278 |
<div class="mc"><div class="ml">Ann. Return</div>{{hist_ret_trans}}</div>
|
| 279 |
<div class="mc force-red"><div class="ml" data-tooltip="The largest single drop from peak to trough. Measures worst-case historical loss.">Max Drawdown <span style="color:var(--accent-blue);">[?]</span></div>{{hist_maxdd_trans}}</div>
|
solver.py
CHANGED
|
@@ -38,7 +38,27 @@ def _ef_cache_key(exp_rets_arr: np.ndarray, Sigma: np.ndarray) -> str:
|
|
| 38 |
|
| 39 |
from math_utils import compute_risk_contributions
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
|
|
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, state: Optional[PortfolioState] = None, cfg = None,
|
|
@@ -89,6 +109,36 @@ def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, stat
|
|
| 89 |
if state is None:
|
| 90 |
state = PortfolioState.empty(tickers)
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
rfr = cfg["risk_free_rate"]
|
| 93 |
sector_limit = cfg["sector_limit"]
|
| 94 |
asset_min = cfg.get("single_asset_min", 0.0)
|
|
@@ -135,7 +185,11 @@ def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, stat
|
|
| 135 |
pre_tax_rets = exp_rets.copy()
|
| 136 |
tax_rate_applied = cfg.get('tax_rate_lt', 0.20) if cfg.get('tax_enabled', False) else 0.0
|
| 137 |
|
| 138 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
durations = np.zeros(n)
|
| 140 |
if yield_df is not None and not yield_df.empty and _HAS_FIXED_INCOME:
|
| 141 |
current_yields = yield_df.iloc[-1].to_dict()
|
|
@@ -298,6 +352,98 @@ def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, stat
|
|
| 298 |
model_info=model_info
|
| 299 |
)
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 302 |
# OPTION 1: CVXPY MEAN-VARIANCE ALLOCATION
|
| 303 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -337,28 +483,6 @@ def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, stat
|
|
| 337 |
binding_constraints = cvx_res.binding_constraints
|
| 338 |
relaxation_log = cvx_res.relaxation_log
|
| 339 |
|
| 340 |
-
class LazyModelInfo(dict):
|
| 341 |
-
def __init__(self, *args, lazy_getters=None, **kwargs):
|
| 342 |
-
super().__init__(*args, **kwargs)
|
| 343 |
-
self.lazy_getters = lazy_getters or {}
|
| 344 |
-
|
| 345 |
-
def __getitem__(self, key):
|
| 346 |
-
if key in self.lazy_getters:
|
| 347 |
-
val = self.lazy_getters.pop(key)()
|
| 348 |
-
self[key] = val
|
| 349 |
-
return val
|
| 350 |
-
return super().__getitem__(key)
|
| 351 |
-
|
| 352 |
-
def get(self, key, default=None):
|
| 353 |
-
if key in self.lazy_getters:
|
| 354 |
-
val = self.lazy_getters.pop(key)()
|
| 355 |
-
self[key] = val
|
| 356 |
-
return val
|
| 357 |
-
return super().get(key, default)
|
| 358 |
-
|
| 359 |
-
def __contains__(self, key):
|
| 360 |
-
return key in self.lazy_getters or super().__contains__(key)
|
| 361 |
-
|
| 362 |
def _compute_ef_curve():
|
| 363 |
ef_curve: Dict[str, List[Any]] = {"vols": [], "rets": []}
|
| 364 |
if silent:
|
|
|
|
| 38 |
|
| 39 |
from math_utils import compute_risk_contributions
|
| 40 |
|
| 41 |
+
class LazyModelInfo(dict):
|
| 42 |
+
def __init__(self, *args, lazy_getters=None, **kwargs):
|
| 43 |
+
super().__init__(*args, **kwargs)
|
| 44 |
+
self.lazy_getters = lazy_getters or {}
|
| 45 |
+
|
| 46 |
+
def __getitem__(self, key):
|
| 47 |
+
if key in self.lazy_getters:
|
| 48 |
+
val = self.lazy_getters.pop(key)()
|
| 49 |
+
self[key] = val
|
| 50 |
+
return val
|
| 51 |
+
return super().__getitem__(key)
|
| 52 |
+
|
| 53 |
+
def get(self, key, default=None):
|
| 54 |
+
if key in self.lazy_getters:
|
| 55 |
+
val = self.lazy_getters.pop(key)()
|
| 56 |
+
self[key] = val
|
| 57 |
+
return val
|
| 58 |
+
return super().get(key, default)
|
| 59 |
|
| 60 |
+
def __contains__(self, key):
|
| 61 |
+
return key in self.lazy_getters or super().__contains__(key)
|
| 62 |
|
| 63 |
|
| 64 |
def build_and_optimize(returns_df, benchmark_rets, risk_input, risk_factor, state: Optional[PortfolioState] = None, cfg = None,
|
|
|
|
| 109 |
if state is None:
|
| 110 |
state = PortfolioState.empty(tickers)
|
| 111 |
|
| 112 |
+
# --- Pre-flight Check: Single Asset Universe ---
|
| 113 |
+
if n == 1:
|
| 114 |
+
if not silent:
|
| 115 |
+
print(f" {Color.DIM}[INFO] Pre-flight check: Single asset universe detected. Bypassing optimization cascade.{Color.RESET}")
|
| 116 |
+
w = pd.Series([min(1.0, cfg["single_asset_max"])], index=tickers)
|
| 117 |
+
if w.sum() < 1.0:
|
| 118 |
+
w['CASH'] = 1.0 - w.sum()
|
| 119 |
+
|
| 120 |
+
# We need a minimal valid payload
|
| 121 |
+
model_info = {
|
| 122 |
+
"name": MODEL_NAMES.get(model, "Custom"),
|
| 123 |
+
"model_id": model,
|
| 124 |
+
"engine_id": allocation_engine,
|
| 125 |
+
"relaxation_log": ["Universe too small to optimize (N=1). Allocated naively."],
|
| 126 |
+
"cov_mat": pd.DataFrame([[returns_df.iloc[:,0].var()]], index=tickers, columns=tickers),
|
| 127 |
+
"exp_rets": pd.Series([returns_df.iloc[:,0].mean() * 252], index=tickers),
|
| 128 |
+
"tax_rate": cfg.get('tax_rate_lt', 0.20)
|
| 129 |
+
}
|
| 130 |
+
res = OptimizationResult(
|
| 131 |
+
weights=w.to_dict(),
|
| 132 |
+
expected_return=float(model_info["exp_rets"].iloc[0] * w[tickers[0]]),
|
| 133 |
+
volatility=float(np.sqrt(model_info["cov_mat"].iloc[0,0]) * w[tickers[0]] * np.sqrt(252)),
|
| 134 |
+
sharpe=0.0,
|
| 135 |
+
turnover=0.0,
|
| 136 |
+
max_drawdown=0.0,
|
| 137 |
+
model_info=model_info,
|
| 138 |
+
status="optimal"
|
| 139 |
+
)
|
| 140 |
+
return res, {}, w
|
| 141 |
+
|
| 142 |
rfr = cfg["risk_free_rate"]
|
| 143 |
sector_limit = cfg["sector_limit"]
|
| 144 |
asset_min = cfg.get("single_asset_min", 0.0)
|
|
|
|
| 185 |
pre_tax_rets = exp_rets.copy()
|
| 186 |
tax_rate_applied = cfg.get('tax_rate_lt', 0.20) if cfg.get('tax_enabled', False) else 0.0
|
| 187 |
|
| 188 |
+
# User Request: Make tax penalties impact the expected return surface directly
|
| 189 |
+
if tax_rate_applied > 0:
|
| 190 |
+
# Assuming tax is paid on the capital gain. For expected returns, we shrink the gross expectation.
|
| 191 |
+
exp_rets = exp_rets * (1.0 - tax_rate_applied)
|
| 192 |
+
|
| 193 |
durations = np.zeros(n)
|
| 194 |
if yield_df is not None and not yield_df.empty and _HAS_FIXED_INCOME:
|
| 195 |
current_yields = yield_df.iloc[-1].to_dict()
|
|
|
|
| 352 |
model_info=model_info
|
| 353 |
)
|
| 354 |
|
| 355 |
+
|
| 356 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 357 |
+
# OPTION 4: MAXIMUM SHARPE RATIO (MSR)
|
| 358 |
+
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 359 |
+
if allocation_engine == 4:
|
| 360 |
+
if not silent:
|
| 361 |
+
print(f" {Color.DIM}[INFO] Allocation Engine: Maximum Sharpe Ratio (MSR).{Color.RESET}")
|
| 362 |
+
|
| 363 |
+
y = cp.Variable(n)
|
| 364 |
+
mu_clean = np.nan_to_num(exp_rets.values, nan=0.0, posinf=0.0, neginf=0.0)
|
| 365 |
+
|
| 366 |
+
max_mu = np.max(mu_clean)
|
| 367 |
+
if max_mu <= 0:
|
| 368 |
+
mu_shift = abs(max_mu) + 0.01
|
| 369 |
+
mu_clean = mu_clean + mu_shift
|
| 370 |
+
|
| 371 |
+
Sigma_clean = make_nearest_psd(cov_res.covariance.values)
|
| 372 |
+
|
| 373 |
+
obj = cp.Minimize((1/2) * cp.quad_form(y, cp.psd_wrap(Sigma_clean)))
|
| 374 |
+
cons = [
|
| 375 |
+
y @ mu_clean == 1,
|
| 376 |
+
y >= 0
|
| 377 |
+
]
|
| 378 |
+
|
| 379 |
+
if asset_max < 1.0:
|
| 380 |
+
cons.append(y <= asset_max * cp.sum(y))
|
| 381 |
+
|
| 382 |
+
prob = cp.Problem(obj, cons)
|
| 383 |
+
try:
|
| 384 |
+
prob.solve(solver=cp.ECOS)
|
| 385 |
+
except Exception:
|
| 386 |
+
try:
|
| 387 |
+
prob.solve(solver=cp.SCS)
|
| 388 |
+
except Exception:
|
| 389 |
+
pass
|
| 390 |
+
|
| 391 |
+
if y.value is None:
|
| 392 |
+
msr_w = pd.Series(1.0/n, index=tickers)
|
| 393 |
+
else:
|
| 394 |
+
y_val = np.maximum(y.value, 0.0)
|
| 395 |
+
if np.sum(y_val) == 0:
|
| 396 |
+
msr_w = pd.Series(1.0/n, index=tickers)
|
| 397 |
+
else:
|
| 398 |
+
w_val = y_val / np.sum(y_val)
|
| 399 |
+
msr_w = pd.Series(w_val, index=tickers)
|
| 400 |
+
|
| 401 |
+
cash_weight = 1.0 - float(msr_w.sum())
|
| 402 |
+
final_w = msr_w.copy()
|
| 403 |
+
if cash_weight > 0.005 or cash_weight < -0.005:
|
| 404 |
+
final_w['CASH'] = cash_weight
|
| 405 |
+
|
| 406 |
+
rc_series = compute_risk_contributions(msr_w, cov_res.covariance)
|
| 407 |
+
if 'CASH' in final_w:
|
| 408 |
+
rc_series['CASH'] = 0.0
|
| 409 |
+
|
| 410 |
+
model_info = {
|
| 411 |
+
"name": MODEL_NAMES.get(model, "Custom"),
|
| 412 |
+
"model_id": model,
|
| 413 |
+
"engine_id": allocation_engine,
|
| 414 |
+
"lw_alpha": lw_alpha,
|
| 415 |
+
"js_alpha": js_alpha,
|
| 416 |
+
"hist_rets": hist_rets,
|
| 417 |
+
"capm_rets": capm_rets,
|
| 418 |
+
"ff_betas": ff_betas,
|
| 419 |
+
"cvar": None,
|
| 420 |
+
"cov_mat": cov_res.covariance,
|
| 421 |
+
"exp_rets": exp_rets.copy(),
|
| 422 |
+
"b_min": b_min,
|
| 423 |
+
"b_max": b_max,
|
| 424 |
+
"pre_tax_rets": pre_tax_rets,
|
| 425 |
+
"tax_rate": cfg.get('tax_rate_lt', 0.20),
|
| 426 |
+
"garch_info": garch_info,
|
| 427 |
+
"cvar_alpha": None,
|
| 428 |
+
"cvar_lambda": None,
|
| 429 |
+
"feature_importances": getattr(forecast, 'feature_importances', None),
|
| 430 |
+
"risk_contributions": rc_series,
|
| 431 |
+
"portfolio_duration": float(np.dot(msr_w.values, durations)),
|
| 432 |
+
"relaxation_log": ["MSR Solved via Charnes-Cooper transformation."],
|
| 433 |
+
"binding_constraints": {},
|
| 434 |
+
"display_constraints": {},
|
| 435 |
+
"ef_curve": {"vols": [], "rets": []}
|
| 436 |
+
}
|
| 437 |
+
return OptimizationResult(
|
| 438 |
+
weights=final_w,
|
| 439 |
+
expected_returns=exp_rets,
|
| 440 |
+
covariance_matrix=cov_res.covariance,
|
| 441 |
+
volatility=cov_res.volatility,
|
| 442 |
+
correlation_matrix=cov_res.correlation,
|
| 443 |
+
betas=betas,
|
| 444 |
+
model_info=model_info
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
| 448 |
# OPTION 1: CVXPY MEAN-VARIANCE ALLOCATION
|
| 449 |
# βββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 483 |
binding_constraints = cvx_res.binding_constraints
|
| 484 |
relaxation_log = cvx_res.relaxation_log
|
| 485 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
def _compute_ef_curve():
|
| 487 |
ef_curve: Dict[str, List[Any]] = {"vols": [], "rets": []}
|
| 488 |
if silent:
|
static/admin.js
CHANGED
|
@@ -222,13 +222,26 @@ function copyKey() {
|
|
| 222 |
}
|
| 223 |
|
| 224 |
async function clearBacktests() {
|
| 225 |
-
if
|
| 226 |
-
if (!confirm('Are you absolutely sure you want to delete ALL backtest history across all users? This action cannot be undone.')) return;
|
| 227 |
-
|
| 228 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
const res = await fetch('/api/admin/clear_backtests', {
|
| 230 |
method: 'POST',
|
| 231 |
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
body: JSON.stringify({ admin_key: adminKey })
|
| 233 |
});
|
| 234 |
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
async function clearBacktests() {
|
| 225 |
+
if(!confirm("Are you sure you want to delete ALL backtest history globally?")) return;
|
|
|
|
|
|
|
| 226 |
try {
|
| 227 |
+
const tokenRes = await fetch(`/api/admin/action_token?action=clear_backtests`, {
|
| 228 |
+
headers: { 'admin-key': getAdminKey() }
|
| 229 |
+
});
|
| 230 |
+
if(!tokenRes.ok) throw new Error("Failed to get confirmation token");
|
| 231 |
+
const tokenData = await tokenRes.json();
|
| 232 |
+
|
| 233 |
const res = await fetch('/api/admin/clear_backtests', {
|
| 234 |
method: 'POST',
|
| 235 |
headers: { 'Content-Type': 'application/json' },
|
| 236 |
+
body: JSON.stringify({ admin_key: getAdminKey(), confirm_token: tokenData.token })
|
| 237 |
+
});
|
| 238 |
+
if(res.ok) {
|
| 239 |
+
alert("Backtest history cleared.");
|
| 240 |
+
} else {
|
| 241 |
+
alert("Failed to clear.");
|
| 242 |
+
}
|
| 243 |
+
} catch(e) { alert(e.message); }
|
| 244 |
+
},
|
| 245 |
body: JSON.stringify({ admin_key: adminKey })
|
| 246 |
});
|
| 247 |
|
static/app.js
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/app_clean.js
ADDED
|
@@ -0,0 +1,1194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Global Variables
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
let debounceTimer;
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
// --- INITIALIZATION ---
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
// Expandable Cards to Modal Logic
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
document.addEventListener('click', (e) => {
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
const card = e.target.closest('.expandable-card');
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
const isExpandBtn = e.target.closest('.card-expand-btn');
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if (card && (isExpandBtn || e.target.tagName === 'H3' || e.target.closest('.card-header-flex'))) {
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
// Extract content
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
const title = card.querySelector('h3').innerText;
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
const bodyHtml = card.querySelector('.card-body').innerHTML;
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
// Populate modal
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
document.getElementById('modalTitle').innerText = title;
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
document.getElementById('modalBody').innerHTML = bodyHtml;
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
// Show modal
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
document.getElementById('globalModal').classList.add('show');
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
window.closeModal = function() {
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
document.getElementById('globalModal').classList.remove('show');
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
window.toggleSidebar = function() {
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
const sidebar = document.getElementById('appSidebar');
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
const mainContent = document.querySelector('.main-content');
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if(sidebar) sidebar.classList.toggle('open');
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
if(mainContent) mainContent.classList.toggle('sidebar-open');
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
// Mouse Glow Tracking
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
document.addEventListener("mousemove", (e) => {
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
document.querySelectorAll(".mouse-glow, .glass-panel, .expandable-card").forEach((el) => {
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
const rect = el.getBoundingClientRect();
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
el.style.setProperty("--mouse-x", `${e.clientX - rect.left}px`);
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
el.style.setProperty("--mouse-y", `${e.clientY - rect.top}px`);
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
el.classList.add("mouse-glow"); // dynamically attach glow class if not present
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
initGSAPAnimations();
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
initMarketTicker();
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
initFinanceNews();
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
// Initialize Vanta Background
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
initVantaBackground();
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
const riskSlider = document.getElementById('risk');
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
const riskVal = document.getElementById('riskVal');
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
if (riskSlider && riskVal) {
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
riskSlider.addEventListener('input', (e) => {
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
riskVal.textContent = e.target.value;
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
// GSAP tactical feedback animation
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
gsap.fromTo(riskVal,
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
{ scale: 1.5, color: '#3b82f6', textShadow: '0 0 20px #3b82f6' },
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
{ scale: 1, color: '#f8fafc', textShadow: 'none', duration: 0.4, ease: "back.out(1.7)" }
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
);
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
// Attach form submission to generateFullReport
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
const portfolioForm = document.getElementById('portfolioForm');
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
if (portfolioForm) {
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
portfolioForm.addEventListener('submit', async (e) => {
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
e.preventDefault();
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
await generateFullReport();
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
});
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
// Dynamic Math Panel Updates
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
const modelSelect = document.getElementById('model');
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
if (modelSelect) {
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
modelSelect.addEventListener('change', (e) => {
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
const mathFormula = document.getElementById('active-math-formula');
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
const mathDesc = document.getElementById('active-math-desc');
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
const val = e.target.value;
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
let formula = '';
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
let desc = '';
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
switch(val) {
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
case '1':
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
formula = '$$ \\mathbb{E}[R_i] = R_f + \\beta_i(\\mathbb{E}[R_m] - R_f) $$';
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
desc = 'Capital Asset Pricing Model: Expected return is a function of systematic risk (Beta) against the market baseline.';
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
break;
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
case '2':
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
formula = '$$ E[R] = [(\\tau \\Sigma)^{-1} + P^T \\Omega^{-1} P]^{-1} [(\\tau \\Sigma)^{-1} \\Pi + P^T \\Omega^{-1} Q] $$';
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
desc = 'Black-Litterman: Blends market equilibrium implied returns with subjective investor views using Bayesian updating.';
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
break;
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
case '3':
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
formula = '$$ \\hat{\\mu}_{JS} = (1 - w) \\bar{X} + w \\mu_0 $$';
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
desc = 'Bayesian Shrinkage (James-Stein): Shrinks individual asset expected returns towards a grand mean to reduce estimation error in historical data.';
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
break;
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
case '4':
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
formula = '$$ R_{it} - R_{ft} = \\alpha_i + \\beta_{1i}MKT_t + \\beta_{2i}SMB_t + \\beta_{3i}HML_t + \\epsilon_{it} $$';
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
desc = 'Multifactor Regression: Forecasts alpha using Fama-French structural factors and time-series momentum.';
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
break;
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
case '5':
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
formula = '$$ \\hat{y} = \\sum_{k=1}^{K} f_k(X) + \\lambda \\|\\beta\\|_1 $$';
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
desc = 'Currently modeling predictive alpha via Gradient Boosted Decision Trees with L1-Norm feature selection penalization.';
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
break;
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
case '6':
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
formula = '$$ L_{SPO+}(\\hat{c}, c) = \\max_{w \\in S} \\{ c^T w - 2\\hat{c}^T w \\} + 2\\hat{c}^T w^* - c^T w^* $$';
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
desc = 'Smart Predict-then-Optimize: End-to-end learning that optimizes predictions directly for the downstream portfolio decision loss function.';
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
break;
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
case '7':
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
formula = '$$ P(X_t | S_t) = \\mathcal{N}(\\mu_{S_t}, \\Sigma_{S_t}), \\quad P(S_t | S_{t-1}) = A $$';
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
desc = 'Hidden Markov Model: Detects unobservable latent market regimes (e.g. Bull vs Bear) to dynamically switch alpha models.';
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
break;
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
if (mathFormula && mathDesc) {
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
mathFormula.innerHTML = formula;
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
mathDesc.innerHTML = desc;
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
if (window.MathJax) {
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
MathJax.typesetPromise([mathFormula]);
|
| 597 |
+
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
});
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
// Suite Tabs logic
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
document.querySelectorAll('.suite-tab').forEach(tab => {
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
tab.addEventListener('click', (e) => {
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
document.querySelectorAll('.suite-tab').forEach(t => t.classList.remove('active'));
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
e.target.classList.add('active');
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
// Currently all tabs just show the "View Comprehensive Report" button
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
});
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
});
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
// (Duplicate form listener removed β already attached above)
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
// Router History Listener
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
window.addEventListener('popstate', (e) => {
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
if (e.state && e.state.viewId) {
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
switchView(e.state.viewId, false);
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
} else {
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
// Handle hash fallback or default home
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
const hash = window.location.hash.replace('#', '');
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
if (hash) {
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
switchView(hash, false);
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
} else {
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
switchView('hero', false);
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
// Check initial hash
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
const initialHash = window.location.hash.replace('#', '');
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
if (initialHash) {
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
switchView(initialHash, false);
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
});
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
// --- NAVIGATION ROUTER ---
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
window.switchView = function(viewId, pushHistory = true) {
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
|
| 801 |
+
document.querySelectorAll('.view-section').forEach(el => {
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
el.classList.remove('active');
|
| 807 |
+
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
el.style.opacity = 0;
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
});
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
document.querySelectorAll('.sidebar-link').forEach(el => el.classList.remove('active'));
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
|
| 831 |
+
const targetView = document.getElementById('view-' + viewId);
|
| 832 |
+
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
if(targetView) {
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
targetView.classList.add('active');
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
// Elegant GSAP fade in
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
if (window.gsap) {
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
gsap.fromTo(targetView,
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
{ opacity: 0, y: 30 },
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
{ opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
|
| 871 |
+
);
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
} else {
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
|
| 881 |
+
targetView.style.opacity = 1;
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
const link = document.querySelector(`.sidebar-link[data-target="${viewId}"]`);
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
if(link) link.classList.add('active');
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
if (viewId === 'saved-portfolios') loadSavedPortfolios();
|
| 917 |
+
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
if (viewId === 'backtest-history') loadBacktestHistory();
|
| 922 |
+
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
|
| 931 |
+
if (pushHistory) {
|
| 932 |
+
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
window.history.pushState({ viewId: viewId }, '', '#' + viewId);
|
| 937 |
+
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
};
|
| 947 |
+
|
| 948 |
+
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
// --- GSAP ANIMATIONS ---
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
|
| 960 |
+
|
| 961 |
+
function initGSAPAnimations() {
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
|
| 966 |
+
if (typeof gsap === 'undefined') return;
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
|
| 975 |
+
|
| 976 |
+
gsap.registerPlugin(ScrollTrigger);
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
// Staggered entry for Model Zoo Cards
|
| 987 |
+
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
|
| 991 |
+
document.querySelectorAll('.zoo-grid').forEach(grid => {
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
|
| 995 |
+
|
| 996 |
+
const cards = grid.querySelectorAll('.expandable-card');
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
if (cards.length === 0) return;
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
|
| 1011 |
+
gsap.fromTo(cards,
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
{ opacity: 0, y: 50 },
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
{
|
| 1022 |
+
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
|
| 1026 |
+
opacity: 1,
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
y: 0,
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
|
| 1035 |
+
|
| 1036 |
+
duration: 0.8,
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
stagger: 0.15,
|
| 1042 |
+
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
ease: "power3.out",
|
| 1047 |
+
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
|
| 1051 |
+
scrollTrigger: {
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
|
| 1055 |
+
|
| 1056 |
+
trigger: grid,
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
start: "top 85%"
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
|
| 1076 |
+
);
|
| 1077 |
+
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
+
|
| 1081 |
+
});
|
| 1082 |
+
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
|
| 1095 |
+
|
| 1096 |
+
// --- MARKET TICKER ---
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
|
| 1100 |
+
|
| 1101 |
+
async function initMarketTicker() {
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
|
| 1105 |
+
|
| 1106 |
+
const container = document.getElementById('liveTickerContent');
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
|
| 1111 |
+
if (!container) return;
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
|
| 1115 |
+
|
| 1116 |
+
try {
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
const res = await fetch('/api/market_ticker');
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
|
| 1126 |
+
const data = await res.json();
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
|
| 1130 |
+
|
| 1131 |
+
if(data && Array.isArray(data) && data.length > 0) {
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
+
let html = '';
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
|
| 1140 |
+
|
| 1141 |
+
// Duplicate array for seamless infinite scrolling
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
const displayData = [...data, ...data, ...data];
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
displayData.forEach(item => {
|
| 1152 |
+
|
| 1153 |
+
|
| 1154 |
+
|
| 1155 |
+
|
| 1156 |
+
let colorClass = item.change >= 0 ? 'color: #10b981;' : 'color: #ef4444;';
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
let sign = item.change >= 0 ? '+' : '';
|
| 1162 |
+
|
| 1163 |
+
|
| 1164 |
+
|
| 1165 |
+
|
| 1166 |
+
html += `<div class="ticker-item">
|
| 1167 |
+
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
<strong style="color: #f8fafc; margin-right: 8px;">${item.name}</strong>
|
| 1172 |
+
|
| 1173 |
+
|
| 1174 |
+
|
| 1175 |
+
|
| 1176 |
+
<span>$${item.price}</span>
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
|
| 1181 |
+
<span style="${colorClass} margin-left: 6px; font-weight: 500;">${sign}${(item.change*100).toFixed(2)}%</span>
|
| 1182 |
+
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
|
| 1186 |
+
</div>`;
|
| 1187 |
+
|
| 1188 |
+
|
| 1189 |
+
|
| 1190 |
+
|
| 1191 |
+
});
|
| 1192 |
+
|
| 1193 |
+
|
| 1194 |
+
|
static/index.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/style.css
CHANGED
|
@@ -1386,10 +1386,16 @@ nav { top: 30px; }
|
|
| 1386 |
|
| 1387 |
/* Chat widget */
|
| 1388 |
#chat-window {
|
| 1389 |
-
|
| 1390 |
-
|
| 1391 |
-
|
| 1392 |
-
max-height:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1393 |
}
|
| 1394 |
#chat-toggle-btn { bottom: 1.2rem; right: 1.2rem; }
|
| 1395 |
|
|
@@ -1433,3 +1439,103 @@ nav { top: 30px; }
|
|
| 1433 |
.glass-panel { padding: 1rem; }
|
| 1434 |
}
|
| 1435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1386 |
|
| 1387 |
/* Chat widget */
|
| 1388 |
#chat-window {
|
| 1389 |
+
position: fixed !important;
|
| 1390 |
+
width: 100vw !important;
|
| 1391 |
+
height: 100dvh !important;
|
| 1392 |
+
max-height: 100dvh !important;
|
| 1393 |
+
top: 0 !important;
|
| 1394 |
+
left: 0 !important;
|
| 1395 |
+
bottom: 0 !important;
|
| 1396 |
+
right: 0 !important;
|
| 1397 |
+
border-radius: 0 !important;
|
| 1398 |
+
z-index: 1000 !important;
|
| 1399 |
}
|
| 1400 |
#chat-toggle-btn { bottom: 1.2rem; right: 1.2rem; }
|
| 1401 |
|
|
|
|
| 1439 |
.glass-panel { padding: 1rem; }
|
| 1440 |
}
|
| 1441 |
|
| 1442 |
+
/* --- NOVA Reasoning Toggle --- */
|
| 1443 |
+
.nova-reasoning {
|
| 1444 |
+
margin-bottom: 0.8rem;
|
| 1445 |
+
border-radius: 8px;
|
| 1446 |
+
background: rgba(0, 0, 0, 0.2);
|
| 1447 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 1448 |
+
overflow: hidden;
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
.nova-reasoning summary {
|
| 1452 |
+
cursor: pointer;
|
| 1453 |
+
padding: 0.5rem 0.8rem;
|
| 1454 |
+
font-size: 0.85rem;
|
| 1455 |
+
color: #94a3b8;
|
| 1456 |
+
font-weight: 500;
|
| 1457 |
+
user-select: none;
|
| 1458 |
+
outline: none;
|
| 1459 |
+
transition: background 0.2s;
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
.nova-reasoning summary:hover {
|
| 1463 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1464 |
+
color: #cbd5e1;
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
.nova-reasoning .reasoning-content {
|
| 1468 |
+
padding: 0.8rem;
|
| 1469 |
+
font-size: 0.75rem;
|
| 1470 |
+
font-weight: 300;
|
| 1471 |
+
color: #64748b;
|
| 1472 |
+
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
| 1473 |
+
font-style: italic;
|
| 1474 |
+
background: rgba(0, 0, 0, 0.3);
|
| 1475 |
+
}
|
| 1476 |
+
|
| 1477 |
+
/* --- Top Dashboard Cards --- */
|
| 1478 |
+
.dashboard-top-cards {
|
| 1479 |
+
display: grid;
|
| 1480 |
+
grid-template-columns: repeat(4, 1fr);
|
| 1481 |
+
gap: 1rem;
|
| 1482 |
+
margin-bottom: 1.5rem;
|
| 1483 |
+
padding: 0 1rem;
|
| 1484 |
+
margin-top: 1rem;
|
| 1485 |
+
}
|
| 1486 |
+
.dashboard-card-link {
|
| 1487 |
+
background: rgba(15, 23, 42, 0.6);
|
| 1488 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 1489 |
+
border-radius: 12px;
|
| 1490 |
+
padding: 1.2rem;
|
| 1491 |
+
display: flex;
|
| 1492 |
+
flex-direction: column;
|
| 1493 |
+
align-items: center;
|
| 1494 |
+
justify-content: center;
|
| 1495 |
+
gap: 0.8rem;
|
| 1496 |
+
cursor: pointer;
|
| 1497 |
+
color: #94a3b8;
|
| 1498 |
+
transition: all 0.3s ease;
|
| 1499 |
+
text-align: center;
|
| 1500 |
+
font-weight: 500;
|
| 1501 |
+
}
|
| 1502 |
+
.dashboard-card-link svg {
|
| 1503 |
+
color: #3b82f6;
|
| 1504 |
+
width: 28px;
|
| 1505 |
+
height: 28px;
|
| 1506 |
+
}
|
| 1507 |
+
.dashboard-card-link:hover {
|
| 1508 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1509 |
+
border-color: rgba(255, 255, 255, 0.1);
|
| 1510 |
+
color: #fff;
|
| 1511 |
+
transform: translateY(-2px);
|
| 1512 |
+
}
|
| 1513 |
+
.dashboard-card-link.active {
|
| 1514 |
+
background: rgba(59, 130, 246, 0.1);
|
| 1515 |
+
border-color: rgba(59, 130, 246, 0.3);
|
| 1516 |
+
color: #60a5fa;
|
| 1517 |
+
}
|
| 1518 |
+
|
| 1519 |
+
@media (max-width: 1024px) {
|
| 1520 |
+
.dashboard-top-cards { grid-template-columns: repeat(2, 1fr); }
|
| 1521 |
+
}
|
| 1522 |
+
@media (max-width: 600px) {
|
| 1523 |
+
.dashboard-top-cards { grid-template-columns: 1fr; }
|
| 1524 |
+
}
|
| 1525 |
+
/* --- Chat Expanded State --- */
|
| 1526 |
+
#chat-window.chat-expanded {
|
| 1527 |
+
width: 800px !important;
|
| 1528 |
+
height: 80vh !important;
|
| 1529 |
+
max-width: 95vw !important;
|
| 1530 |
+
bottom: 50px !important;
|
| 1531 |
+
right: 50px !important;
|
| 1532 |
+
}
|
| 1533 |
+
@media (max-width: 800px) {
|
| 1534 |
+
#chat-window.chat-expanded {
|
| 1535 |
+
width: 100% !important;
|
| 1536 |
+
height: 100vh !important;
|
| 1537 |
+
bottom: 0 !important;
|
| 1538 |
+
right: 0 !important;
|
| 1539 |
+
border-radius: 0 !important;
|
| 1540 |
+
}
|
| 1541 |
+
}
|
test_hf_poll.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
headers = {"X-Access-Key": "Ir_yad"}
|
| 3 |
-
try:
|
| 4 |
-
r = requests.get("https://engineportf-portfolio-opt.hf.space/api/status/4ffec6fe-3bd2-4943-8ed1-c02872461355", headers=headers)
|
| 5 |
-
print("STATUS", r.status_code)
|
| 6 |
-
print("TEXT", r.text)
|
| 7 |
-
except Exception as e:
|
| 8 |
-
print("ERR", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_hf_wizard.py
DELETED
|
@@ -1,18 +0,0 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
payload = {
|
| 3 |
-
"tickers": ["SPY", "TLT"],
|
| 4 |
-
"capital": 100000,
|
| 5 |
-
"risk_input": 5,
|
| 6 |
-
"model": 5,
|
| 7 |
-
"allocation_engine": 1,
|
| 8 |
-
"allow_shorting": False,
|
| 9 |
-
"tax_enabled": True,
|
| 10 |
-
"garch_enabled": False,
|
| 11 |
-
"current_weights": {"AAPL": 0.3, "MSFT": 0.7}
|
| 12 |
-
}
|
| 13 |
-
try:
|
| 14 |
-
r = requests.post("https://engineportf-portfolio-opt.hf.space/api/generate", json=payload)
|
| 15 |
-
print("STATUS", r.status_code)
|
| 16 |
-
print("TEXT", r.text)
|
| 17 |
-
except Exception as e:
|
| 18 |
-
print("ERR", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_hf_wizard_auth.py
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
import requests
|
| 2 |
-
payload = {
|
| 3 |
-
"tickers": ["SPY", "TLT", "GLD", "VNQ"],
|
| 4 |
-
"capital": 100000,
|
| 5 |
-
"risk_input": 5,
|
| 6 |
-
"model": 5,
|
| 7 |
-
"allocation_engine": 1,
|
| 8 |
-
"allow_shorting": False,
|
| 9 |
-
"tax_enabled": True,
|
| 10 |
-
"garch_enabled": False,
|
| 11 |
-
"custom_constraints": []
|
| 12 |
-
}
|
| 13 |
-
headers = {"X-Access-Key": "Ir_yad"}
|
| 14 |
-
try:
|
| 15 |
-
r = requests.post("https://engineportf-portfolio-opt.hf.space/api/generate", json=payload, headers=headers)
|
| 16 |
-
print("STATUS", r.status_code)
|
| 17 |
-
print("TEXT", r.text)
|
| 18 |
-
except Exception as e:
|
| 19 |
-
print("ERR", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test_wizard2.py
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
import sys, json
|
| 2 |
-
sys.path.append('.')
|
| 3 |
-
from core_engine import run_engine
|
| 4 |
-
import os
|
| 5 |
-
# Token removed for security
|
| 6 |
-
overrides = {
|
| 7 |
-
"tickers": ["SPY", "TLT"],
|
| 8 |
-
"capital": 100000,
|
| 9 |
-
"risk_input": 5,
|
| 10 |
-
"model": 5,
|
| 11 |
-
"allocation_engine": 1,
|
| 12 |
-
"allow_shorting": False,
|
| 13 |
-
"tax_enabled": True,
|
| 14 |
-
"garch_enabled": False,
|
| 15 |
-
"current_weights": {}
|
| 16 |
-
}
|
| 17 |
-
print("Starting...")
|
| 18 |
-
try:
|
| 19 |
-
res = run_engine(overrides=overrides, serve=False, task_id="test_wizard")
|
| 20 |
-
print("SUCCESS", res)
|
| 21 |
-
except Exception as e:
|
| 22 |
-
import traceback
|
| 23 |
-
traceback.print_exc()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_perf.py
CHANGED
|
@@ -7,20 +7,21 @@ from core_types import ForecastResult, CovarianceResult
|
|
| 7 |
def run_perf():
|
| 8 |
n = 200
|
| 9 |
np.random.seed(42)
|
|
|
|
| 10 |
rets = np.random.randn(1000, n) * 0.01
|
| 11 |
-
df = pd.DataFrame(rets)
|
| 12 |
Sigma = df.cov()
|
| 13 |
|
| 14 |
fr = ForecastResult(
|
| 15 |
-
expected_returns=pd.Series(df.mean() * 252),
|
| 16 |
-
covariance_result=CovarianceResult(Sigma, df.corr(), pd.Series(df.std())),
|
| 17 |
-
betas=pd.Series(np.ones(n)),
|
| 18 |
garch_info={},
|
| 19 |
js_alpha=0.0,
|
| 20 |
-
capm_rets=pd.Series(np.ones(n)),
|
| 21 |
ff_betas=pd.DataFrame(),
|
| 22 |
periods=252,
|
| 23 |
-
historical_returns=pd.Series(df.mean() * 252)
|
| 24 |
)
|
| 25 |
|
| 26 |
cfg = {"risk_free_rate": 0.04, "gross_leverage_cap": 1.0, "sector_limit": 0.35, "single_asset_max": 0.40, "tc_volume_profile": 0.10}
|
|
@@ -29,7 +30,7 @@ def run_perf():
|
|
| 29 |
start = time.time()
|
| 30 |
for i in range(5):
|
| 31 |
engine = CVXPYOptimizationEngine(
|
| 32 |
-
forecast=fr, state=state, cfg=cfg, tickers=
|
| 33 |
macro={}, spread_map={}, risk_input=5.0, risk_factor=1.0,
|
| 34 |
capital=1_000_000.0, adv_proxy=50_000_000.0, safe_min=0.0, asset_max=0.4,
|
| 35 |
sector_limit=0.35, allow_shorts=False, durations=np.zeros(n),
|
|
|
|
| 7 |
def run_perf():
|
| 8 |
n = 200
|
| 9 |
np.random.seed(42)
|
| 10 |
+
tickers = [f"T{i}" for i in range(n)]
|
| 11 |
rets = np.random.randn(1000, n) * 0.01
|
| 12 |
+
df = pd.DataFrame(rets, columns=tickers)
|
| 13 |
Sigma = df.cov()
|
| 14 |
|
| 15 |
fr = ForecastResult(
|
| 16 |
+
expected_returns=pd.Series(df.mean() * 252, index=tickers),
|
| 17 |
+
covariance_result=CovarianceResult(Sigma, df.corr(), pd.Series(df.std(), index=tickers)),
|
| 18 |
+
betas=pd.Series(np.ones(n), index=tickers),
|
| 19 |
garch_info={},
|
| 20 |
js_alpha=0.0,
|
| 21 |
+
capm_rets=pd.Series(np.ones(n), index=tickers),
|
| 22 |
ff_betas=pd.DataFrame(),
|
| 23 |
periods=252,
|
| 24 |
+
historical_returns=pd.Series(df.mean() * 252, index=tickers)
|
| 25 |
)
|
| 26 |
|
| 27 |
cfg = {"risk_free_rate": 0.04, "gross_leverage_cap": 1.0, "sector_limit": 0.35, "single_asset_max": 0.40, "tc_volume_profile": 0.10}
|
|
|
|
| 30 |
start = time.time()
|
| 31 |
for i in range(5):
|
| 32 |
engine = CVXPYOptimizationEngine(
|
| 33 |
+
forecast=fr, state=state, cfg=cfg, tickers=tickers, n=n,
|
| 34 |
macro={}, spread_map={}, risk_input=5.0, risk_factor=1.0,
|
| 35 |
capital=1_000_000.0, adv_proxy=50_000_000.0, safe_min=0.0, asset_max=0.4,
|
| 36 |
sector_limit=0.35, allow_shorts=False, durations=np.zeros(n),
|
update_admin.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, re
|
| 2 |
+
|
| 3 |
+
app_code = open('app.py', 'r', encoding='utf-8').read()
|
| 4 |
+
|
| 5 |
+
action_token_code = '''
|
| 6 |
+
import time, hashlib
|
| 7 |
+
def get_action_token(action: str, admin_key: str) -> str:
|
| 8 |
+
window = int(time.time() / 120)
|
| 9 |
+
return hashlib.sha256(f"{admin_key}:{action}:{window}".encode()).hexdigest()
|
| 10 |
+
|
| 11 |
+
@app.get("/api/admin/action_token")
|
| 12 |
+
async def get_admin_action_token(action: str, admin_key: str = Header(...)):
|
| 13 |
+
if admin_key != access_manager.MASTER_KEY:
|
| 14 |
+
raise HTTPException(status_code=401, detail="Invalid Admin Key")
|
| 15 |
+
return {"token": get_action_token(action, admin_key)}
|
| 16 |
+
'''
|
| 17 |
+
|
| 18 |
+
if 'get_action_token' not in app_code:
|
| 19 |
+
app_code = app_code.replace('@app.post("/api/admin/revoke_all")', action_token_code + '\n@app.post("/api/admin/revoke_all")')
|
| 20 |
+
|
| 21 |
+
app_code = re.sub(r'class AdminRevokeRequest\(BaseModel\):\n admin_key: str', 'class AdminRevokeRequest(BaseModel):\n admin_key: str\n target_key: str = ""\n confirm_token: str = ""', app_code)
|
| 22 |
+
app_code = re.sub(r'class AdminClearRequest\(BaseModel\):\n admin_key: str', 'class AdminClearRequest(BaseModel):\n admin_key: str\n confirm_token: str = ""', app_code)
|
| 23 |
+
|
| 24 |
+
if 'req.confirm_token != get_action_token("revoke_all"' not in app_code:
|
| 25 |
+
app_code = app_code.replace(
|
| 26 |
+
'if req.admin_key != access_manager.MASTER_KEY:\n raise HTTPException(status_code=401, detail="Invalid Admin Key")',
|
| 27 |
+
'if req.admin_key != access_manager.MASTER_KEY:\n raise HTTPException(status_code=401, detail="Invalid Admin Key")\n if getattr(req, "confirm_token", "") != get_action_token("revoke_all", req.admin_key):\n raise HTTPException(status_code=403, detail="Invalid or expired confirmation token")'
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
if 'req.confirm_token != get_action_token("clear_backtests"' not in app_code:
|
| 31 |
+
app_code = app_code.replace(
|
| 32 |
+
'def admin_clear_backtests(req: AdminClearRequest, db: Session = Depends(get_db)):\n if req.admin_key != access_manager.MASTER_KEY:\n raise HTTPException(status_code=401, detail="Invalid Admin Key")',
|
| 33 |
+
'def admin_clear_backtests(req: AdminClearRequest, db: Session = Depends(get_db)):\n if req.admin_key != access_manager.MASTER_KEY:\n raise HTTPException(status_code=401, detail="Invalid Admin Key")\n if getattr(req, "confirm_token", "") != get_action_token("clear_backtests", req.admin_key):\n raise HTTPException(status_code=403, detail="Invalid or expired confirmation token")'
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
with open('app.py', 'w', encoding='utf-8') as f:
|
| 37 |
+
f.write(app_code)
|
| 38 |
+
|
| 39 |
+
admin_js = open('static/admin.js', 'r', encoding='utf-8').read()
|
| 40 |
+
|
| 41 |
+
revoke_all_js = '''
|
| 42 |
+
async function revokeAll() {
|
| 43 |
+
if(!confirm("Are you absolutely sure you want to revoke ALL keys except the Master Key?")) return;
|
| 44 |
+
try {
|
| 45 |
+
const tokenRes = await fetch(`/api/admin/action_token?action=revoke_all`, {
|
| 46 |
+
headers: { 'admin-key': getAdminKey() }
|
| 47 |
+
});
|
| 48 |
+
if(!tokenRes.ok) throw new Error("Failed to get confirmation token");
|
| 49 |
+
const tokenData = await tokenRes.json();
|
| 50 |
+
|
| 51 |
+
const res = await fetch('/api/admin/revoke_all', {
|
| 52 |
+
method: 'POST',
|
| 53 |
+
headers: { 'Content-Type': 'application/json' },
|
| 54 |
+
body: JSON.stringify({ admin_key: getAdminKey(), confirm_token: tokenData.token })
|
| 55 |
+
});
|
| 56 |
+
if(res.ok) {
|
| 57 |
+
alert("All keys revoked.");
|
| 58 |
+
loadKeys();
|
| 59 |
+
} else {
|
| 60 |
+
alert("Failed to revoke.");
|
| 61 |
+
}
|
| 62 |
+
} catch(e) { alert(e.message); }
|
| 63 |
+
}
|
| 64 |
+
'''
|
| 65 |
+
|
| 66 |
+
clear_backtests_js = '''
|
| 67 |
+
async function clearBacktests() {
|
| 68 |
+
if(!confirm("Are you sure you want to delete ALL backtest history globally?")) return;
|
| 69 |
+
try {
|
| 70 |
+
const tokenRes = await fetch(`/api/admin/action_token?action=clear_backtests`, {
|
| 71 |
+
headers: { 'admin-key': getAdminKey() }
|
| 72 |
+
});
|
| 73 |
+
if(!tokenRes.ok) throw new Error("Failed to get confirmation token");
|
| 74 |
+
const tokenData = await tokenRes.json();
|
| 75 |
+
|
| 76 |
+
const res = await fetch('/api/admin/clear_backtests', {
|
| 77 |
+
method: 'POST',
|
| 78 |
+
headers: { 'Content-Type': 'application/json' },
|
| 79 |
+
body: JSON.stringify({ admin_key: getAdminKey(), confirm_token: tokenData.token })
|
| 80 |
+
});
|
| 81 |
+
if(res.ok) {
|
| 82 |
+
alert("Backtest history cleared.");
|
| 83 |
+
} else {
|
| 84 |
+
alert("Failed to clear.");
|
| 85 |
+
}
|
| 86 |
+
} catch(e) { alert(e.message); }
|
| 87 |
+
}
|
| 88 |
+
'''
|
| 89 |
+
|
| 90 |
+
admin_js = re.sub(r'async function revokeAll\(\) \{[\s\S]*?\}', revoke_all_js.strip(), admin_js)
|
| 91 |
+
admin_js = re.sub(r'async function clearBacktests\(\) \{[\s\S]*?\}', clear_backtests_js.strip(), admin_js)
|
| 92 |
+
|
| 93 |
+
with open('static/admin.js', 'w', encoding='utf-8') as f:
|
| 94 |
+
f.write(admin_js)
|
| 95 |
+
|
| 96 |
+
print("Updated app.py and admin.js with confirmation tokens")
|
update_app.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
with open('static/app.js', 'r', encoding='utf-8') as f:
|
| 4 |
+
text = f.read()
|
| 5 |
+
|
| 6 |
+
toggle_logic = '''
|
| 7 |
+
window.toggleChatExpand = function() {
|
| 8 |
+
const chatWin = document.getElementById('chat-window');
|
| 9 |
+
if (!chatWin) return;
|
| 10 |
+
if (chatWin.style.width === '80vw') {
|
| 11 |
+
chatWin.style.width = '380px';
|
| 12 |
+
chatWin.style.height = '500px';
|
| 13 |
+
} else {
|
| 14 |
+
chatWin.style.width = '80vw';
|
| 15 |
+
chatWin.style.height = '80vh';
|
| 16 |
+
chatWin.style.left = '10vw';
|
| 17 |
+
chatWin.style.top = '10vh';
|
| 18 |
+
}
|
| 19 |
+
};
|
| 20 |
+
window.toggleChatWindow = function() {
|
| 21 |
+
const chatWin = document.getElementById('chat-window');
|
| 22 |
+
const toggleBtn = document.getElementById('chat-toggle-btn');
|
| 23 |
+
if (!chatWin) return;
|
| 24 |
+
if (chatWin.style.display === 'none' || chatWin.style.display === '') {
|
| 25 |
+
chatWin.style.display = 'flex';
|
| 26 |
+
toggleBtn.style.transform = 'scale(0.8)';
|
| 27 |
+
} else {
|
| 28 |
+
chatWin.style.display = 'none';
|
| 29 |
+
toggleBtn.style.transform = 'scale(1)';
|
| 30 |
+
}
|
| 31 |
+
};
|
| 32 |
+
'''
|
| 33 |
+
if 'window.toggleChatExpand' not in text:
|
| 34 |
+
text += '\n' + toggle_logic
|
| 35 |
+
|
| 36 |
+
html = open('static/index.html', 'r', encoding='utf-8').read()
|
| 37 |
+
html = html.replace('id="chat-toggle-btn" onclick="toggleChatExpand()"', 'id="chat-toggle-btn" onclick="toggleChatWindow()"')
|
| 38 |
+
with open('static/index.html', 'w', encoding='utf-8') as f:
|
| 39 |
+
f.write(html)
|
| 40 |
+
|
| 41 |
+
modal_logic = '''
|
| 42 |
+
window.showAIActionModal = function(actData) {
|
| 43 |
+
const overlay = document.createElement('div');
|
| 44 |
+
overlay.style.cssText = 'position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.8); z-index:10000; display:flex; align-items:center; justify-content:center; backdrop-filter:blur(5px);';
|
| 45 |
+
const modal = document.createElement('div');
|
| 46 |
+
modal.style.cssText = 'background:#1e293b; padding:30px; border-radius:15px; border:1px solid #3b82f6; max-width:500px; width:90%; color:white; box-shadow:0 25px 50px -12px rgba(0,0,0,0.5); font-family:"Inter",sans-serif;';
|
| 47 |
+
|
| 48 |
+
modal.innerHTML = `
|
| 49 |
+
<h2 style="margin-top:0; color:#60a5fa; display:flex; align-items:center; gap:10px;">
|
| 50 |
+
<span>\u26A1</span> NOVA Action Required
|
| 51 |
+
</h2>
|
| 52 |
+
<p style="color:#cbd5e1; margin-bottom:20px;">NOVA wants to update your portfolio with the following parameters:</p>
|
| 53 |
+
<pre style="background:#0f172a; padding:15px; border-radius:8px; overflow-x:auto; color:#a78bfa; border:1px solid #334155; margin-bottom:25px;">${JSON.stringify(actData, null, 2)}</pre>
|
| 54 |
+
<div style="display:flex; justify-content:flex-end; gap:15px;">
|
| 55 |
+
<button id="nova-cancel" style="background:transparent; border:1px solid #475569; color:#94a3b8; padding:10px 20px; border-radius:8px; cursor:pointer; transition:all 0.2s;">Cancel</button>
|
| 56 |
+
<button id="nova-confirm" style="background:#3b82f6; border:none; color:white; padding:10px 25px; border-radius:8px; cursor:pointer; font-weight:bold; box-shadow:0 4px 6px -1px rgba(59,130,246,0.5); transition:all 0.2s;">Approve & Execute</button>
|
| 57 |
+
</div>
|
| 58 |
+
`;
|
| 59 |
+
overlay.appendChild(modal);
|
| 60 |
+
document.body.appendChild(overlay);
|
| 61 |
+
|
| 62 |
+
document.getElementById('nova-cancel').onclick = () => { document.body.removeChild(overlay); };
|
| 63 |
+
document.getElementById('nova-confirm').onclick = () => {
|
| 64 |
+
document.body.removeChild(overlay);
|
| 65 |
+
if(actData.tickers) document.getElementById('tickers').value = actData.tickers;
|
| 66 |
+
if(actData.capital) document.getElementById('capital').value = actData.capital;
|
| 67 |
+
if(actData.risk) { document.getElementById('risk').value = actData.risk; document.getElementById('riskVal').textContent = actData.risk; }
|
| 68 |
+
if(actData.model) document.getElementById('model').value = actData.model;
|
| 69 |
+
if(actData.currency) document.getElementById('currency').value = actData.currency;
|
| 70 |
+
const engineBtn = document.getElementById('run-btn') || document.querySelector('button[onclick="runEngine()"]');
|
| 71 |
+
if(engineBtn) engineBtn.click();
|
| 72 |
+
else if (window.runEngine) window.runEngine();
|
| 73 |
+
};
|
| 74 |
+
};
|
| 75 |
+
'''
|
| 76 |
+
if 'window.showAIActionModal' not in text:
|
| 77 |
+
text += '\n' + modal_logic
|
| 78 |
+
|
| 79 |
+
old_chat_parse = ''' const responseText = data.response || data.reply || (data.detail ? "Error: " + data.detail : "No response from AI.");
|
| 80 |
+
chatHistory.push({ role: "assistant", content: responseText });
|
| 81 |
+
const aiMsg = document.createElement('div');
|
| 82 |
+
aiMsg.style.cssText = "background: rgba(59, 130, 246, 0.1); padding: 12px 16px; border-radius: 12px; border-top-left-radius: 4px; align-self: flex-start; max-width: 85%; color: #e2e8f0; line-height: 1.5;";
|
| 83 |
+
aiMsg.innerText = responseText;'''
|
| 84 |
+
|
| 85 |
+
new_chat_parse = ''' const responseText = data.response || data.reply || (data.detail ? "Error: " + data.detail : "No response from AI.");
|
| 86 |
+
|
| 87 |
+
const actMatch = responseText.match(/<<<ACT:\\s*(\\{.*?\\})\\s*>>>/s);
|
| 88 |
+
let cleanText = responseText;
|
| 89 |
+
if (actMatch) {
|
| 90 |
+
cleanText = responseText.replace(actMatch[0], '').trim();
|
| 91 |
+
try {
|
| 92 |
+
const actData = JSON.parse(actMatch[1]);
|
| 93 |
+
window.showAIActionModal(actData);
|
| 94 |
+
} catch(e) { console.error("Failed to parse ACT block", e); }
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
chatHistory.push({ role: "assistant", content: cleanText });
|
| 98 |
+
const aiMsg = document.createElement('div');
|
| 99 |
+
aiMsg.style.cssText = "background: rgba(59, 130, 246, 0.1); padding: 12px 16px; border-radius: 12px; border-top-left-radius: 4px; align-self: flex-start; max-width: 85%; color: #e2e8f0; line-height: 1.5;";
|
| 100 |
+
aiMsg.innerText = cleanText;'''
|
| 101 |
+
|
| 102 |
+
text = text.replace(old_chat_parse, new_chat_parse)
|
| 103 |
+
|
| 104 |
+
text += '''
|
| 105 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 106 |
+
const closeBtn = document.getElementById('chat-close-btn');
|
| 107 |
+
if(closeBtn) closeBtn.onclick = window.toggleChatWindow;
|
| 108 |
+
});
|
| 109 |
+
'''
|
| 110 |
+
|
| 111 |
+
with open('static/app.js', 'w', encoding='utf-8') as f:
|
| 112 |
+
f.write(text)
|
| 113 |
+
print('Updated app.js successfully with AI sandbox modal and fix for buttons.')
|
utils/metrics.py
CHANGED
|
@@ -7,7 +7,8 @@ def israelsen_sharpe(excess_return, vol):
|
|
| 7 |
Standard Sharpe penalizes higher volatility even when returns are negative.
|
| 8 |
This adjustment correctly rewards lower volatility when returns are negative.
|
| 9 |
"""
|
| 10 |
-
|
|
|
|
| 11 |
return 0.0
|
| 12 |
exponent = excess_return / abs(excess_return)
|
| 13 |
return excess_return / (vol ** exponent)
|
|
|
|
| 7 |
Standard Sharpe penalizes higher volatility even when returns are negative.
|
| 8 |
This adjustment correctly rewards lower volatility when returns are negative.
|
| 9 |
"""
|
| 10 |
+
vol = max(float(vol), 1e-4)
|
| 11 |
+
if excess_return == 0:
|
| 12 |
return 0.0
|
| 13 |
exponent = excess_return / abs(excess_return)
|
| 14 |
return excess_return / (vol ** exponent)
|
validation.py
CHANGED
|
@@ -252,9 +252,11 @@ def probabilistic_sharpe_ratio(returns, benchmark_sharpe=0.0, periods=252):
|
|
| 252 |
|
| 253 |
return {
|
| 254 |
'obs_sharpe': float(sr_daily * np.sqrt(periods)),
|
|
|
|
| 255 |
'benchmark': float(benchmark_sharpe),
|
| 256 |
'psr_stat': float(psr_stat),
|
| 257 |
'prob': float(prob),
|
|
|
|
| 258 |
'significant': bool(prob > 0.95)
|
| 259 |
}
|
| 260 |
|
|
@@ -400,3 +402,121 @@ def print_validation_report(dm_results=None, var_results=None, psr_results=None,
|
|
| 400 |
else:
|
| 401 |
print(f" Result : {Color.RED}FAIL{Color.RESET} β {var_results.get('diagnostic', 'VaR model failed conditional coverage checks.')}")
|
| 402 |
print("ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
|
| 253 |
return {
|
| 254 |
'obs_sharpe': float(sr_daily * np.sqrt(periods)),
|
| 255 |
+
'observed_sharpe': float(sr_daily * np.sqrt(periods)),
|
| 256 |
'benchmark': float(benchmark_sharpe),
|
| 257 |
'psr_stat': float(psr_stat),
|
| 258 |
'prob': float(prob),
|
| 259 |
+
'p_value': float(1.0 - prob),
|
| 260 |
'significant': bool(prob > 0.95)
|
| 261 |
}
|
| 262 |
|
|
|
|
| 402 |
else:
|
| 403 |
print(f" Result : {Color.RED}FAIL{Color.RESET} β {var_results.get('diagnostic', 'VaR model failed conditional coverage checks.')}")
|
| 404 |
print("ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 408 |
+
# 7. INSTITUTIONAL ECONOMETRIC TESTS
|
| 409 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 410 |
+
|
| 411 |
+
def pesaran_timmermann_test(actual, predicted):
|
| 412 |
+
"""Pesaran and Timmermann (1992) directional accuracy test."""
|
| 413 |
+
a = np.sign(actual)
|
| 414 |
+
p = np.sign(predicted)
|
| 415 |
+
p_hat = np.mean(a == p)
|
| 416 |
+
p_a = np.mean(a > 0)
|
| 417 |
+
p_p = np.mean(p > 0)
|
| 418 |
+
p_star = p_a * p_p + (1 - p_a) * (1 - p_p)
|
| 419 |
+
var_p_hat = p_star * (1 - p_star) / len(actual)
|
| 420 |
+
if var_p_hat == 0:
|
| 421 |
+
return {'stat': 0.0, 'p_value': 1.0, 'significant': False}
|
| 422 |
+
pt_stat = (p_hat - p_star) / np.sqrt(var_p_hat)
|
| 423 |
+
p_val = 1 - norm.cdf(pt_stat)
|
| 424 |
+
return {'stat': float(pt_stat), 'p_value': float(p_val), 'significant': p_val < 0.05}
|
| 425 |
+
|
| 426 |
+
def ljung_box_test(residuals, lags=10):
|
| 427 |
+
"""Ljung-Box test for autocorrelation in residuals."""
|
| 428 |
+
res = np.asarray(residuals).flatten()
|
| 429 |
+
n = len(res)
|
| 430 |
+
acf = []
|
| 431 |
+
for k in range(1, lags + 1):
|
| 432 |
+
if len(res[:-k]) < 2:
|
| 433 |
+
acf.append(0.0)
|
| 434 |
+
continue
|
| 435 |
+
corr = np.corrcoef(res[:-k], res[k:])[0, 1]
|
| 436 |
+
acf.append(corr if not np.isnan(corr) else 0.0)
|
| 437 |
+
acf = np.array(acf)
|
| 438 |
+
q_stat = n * (n + 2) * np.sum((acf ** 2) / (n - np.arange(1, lags + 1)))
|
| 439 |
+
p_val = 1 - chi2.cdf(q_stat, df=lags)
|
| 440 |
+
return {'stat': float(q_stat), 'p_value': float(p_val), 'significant': p_val < 0.05}
|
| 441 |
+
|
| 442 |
+
def grs_test(alpha_hat, resid_cov, factor_rets):
|
| 443 |
+
"""
|
| 444 |
+
Gibbons, Ross, and Shanken (1989) test.
|
| 445 |
+
Tests the null hypothesis that all alphas are jointly zero.
|
| 446 |
+
"""
|
| 447 |
+
T = factor_rets.shape[0]
|
| 448 |
+
N = alpha_hat.shape[0]
|
| 449 |
+
K = factor_rets.shape[1] if len(factor_rets.shape) > 1 else 1
|
| 450 |
+
|
| 451 |
+
if K == 1:
|
| 452 |
+
factor_mean = np.mean(factor_rets)
|
| 453 |
+
factor_var = np.var(factor_rets)
|
| 454 |
+
omega = (factor_mean**2) / factor_var if factor_var > 0 else 0
|
| 455 |
+
else:
|
| 456 |
+
factor_mean = np.mean(factor_rets, axis=0)
|
| 457 |
+
factor_cov = np.cov(factor_rets, rowvar=False)
|
| 458 |
+
try:
|
| 459 |
+
omega = factor_mean.T @ np.linalg.inv(factor_cov) @ factor_mean
|
| 460 |
+
except np.linalg.LinAlgError:
|
| 461 |
+
omega = 0.0
|
| 462 |
+
|
| 463 |
+
try:
|
| 464 |
+
inv_resid_cov = np.linalg.inv(resid_cov)
|
| 465 |
+
except np.linalg.LinAlgError:
|
| 466 |
+
return {'stat': 0.0, 'p_value': 1.0, 'significant': False}
|
| 467 |
+
|
| 468 |
+
stat = (T - N - K) / N * (1 + omega)**(-1) * (alpha_hat.T @ inv_resid_cov @ alpha_hat)
|
| 469 |
+
|
| 470 |
+
from scipy.stats import f
|
| 471 |
+
p_val = 1 - f.cdf(stat, N, T - N - K)
|
| 472 |
+
return {'stat': float(stat), 'p_value': float(p_val), 'significant': p_val < 0.05}
|
| 473 |
+
|
| 474 |
+
def chow_test(y, X, break_point):
|
| 475 |
+
"""Chow test for structural breaks."""
|
| 476 |
+
y = np.asarray(y)
|
| 477 |
+
X = np.asarray(X)
|
| 478 |
+
if len(X.shape) == 1:
|
| 479 |
+
X = X.reshape(-1, 1)
|
| 480 |
+
|
| 481 |
+
def get_rss(y_sub, X_sub):
|
| 482 |
+
if len(y_sub) < X_sub.shape[1]:
|
| 483 |
+
return 0.0
|
| 484 |
+
try:
|
| 485 |
+
b = np.linalg.pinv(X_sub.T @ X_sub) @ X_sub.T @ y_sub
|
| 486 |
+
resid = y_sub - X_sub @ b
|
| 487 |
+
return np.sum(resid**2)
|
| 488 |
+
except np.linalg.LinAlgError:
|
| 489 |
+
return 0.0
|
| 490 |
+
|
| 491 |
+
rss_all = get_rss(y, X)
|
| 492 |
+
rss_1 = get_rss(y[:break_point], X[:break_point])
|
| 493 |
+
rss_2 = get_rss(y[break_point:], X[break_point:])
|
| 494 |
+
|
| 495 |
+
k = X.shape[1]
|
| 496 |
+
n = len(y)
|
| 497 |
+
|
| 498 |
+
num = (rss_all - (rss_1 + rss_2)) / k
|
| 499 |
+
den = (rss_1 + rss_2) / (n - 2*k)
|
| 500 |
+
if den <= 0:
|
| 501 |
+
return {'stat': 0.0, 'p_value': 1.0, 'significant': False}
|
| 502 |
+
chow_stat = num / den
|
| 503 |
+
from scipy.stats import f
|
| 504 |
+
p_val = 1 - f.cdf(chow_stat, k, n - 2*k)
|
| 505 |
+
return {'stat': float(chow_stat), 'p_value': float(p_val), 'significant': p_val < 0.05}
|
| 506 |
+
|
| 507 |
+
def hansen_spa_test(losses_base, losses_models):
|
| 508 |
+
"""
|
| 509 |
+
Simplified Hansen's Superior Predictive Ability (SPA) Test.
|
| 510 |
+
"""
|
| 511 |
+
losses_base = np.asarray(losses_base)
|
| 512 |
+
losses_models = np.asarray(losses_models)
|
| 513 |
+
if len(losses_models.shape) == 1:
|
| 514 |
+
losses_models = losses_models.reshape(-1, 1)
|
| 515 |
+
|
| 516 |
+
d = losses_base[:, None] - losses_models
|
| 517 |
+
mean_d = np.mean(d, axis=0)
|
| 518 |
+
std_d = np.std(d, axis=0, ddof=1)
|
| 519 |
+
stat = np.max(mean_d / (std_d / np.sqrt(len(losses_base)) + 1e-8))
|
| 520 |
+
|
| 521 |
+
p_val = 1 - norm.cdf(stat)
|
| 522 |
+
return {'stat': float(stat), 'p_value': float(p_val), 'significant': p_val < 0.05}
|