engineportf commited on
Commit
395de87
Β·
1 Parent(s): 5ee8052

Security and architecture upgrade

Browse files
.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
- handler = logging.FileHandler(os.path.join(OUTPUT_DIR, "access.log"), encoding='utf-8')
 
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["timestamp"] < NEWS_CACHE_TTL and news_cache["data"]:
341
  return news_cache["data"]
342
 
343
  results = []
344
  try:
345
- import urllib.request
346
  import xml.etree.ElementTree as ET
347
- url = "https://news.google.com/rss/search?q=finance+stock+market&hl=en-US&gl=US&ceid=US:en"
348
- req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
349
- with urllib.request.urlopen(req, timeout=5) as response:
350
- xml_data = response.read()
351
- root = ET.fromstring(xml_data)
 
 
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
- "You are an elite quantitative analyst AI assistant built into the Portfolio Engine. "
 
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 helpful, natural, and adapt to the user's tone. "
694
- "DO NOT blindly recite or summarize the portfolio context unless the user specifically asks a question about their portfolio. "
695
- "If they just say 'hello' or make a general statement, greet them naturally without mentioning the data."
 
 
 
 
 
 
 
 
 
 
696
  )
697
 
698
  # Sassy persona for Master Key users
699
- max_t = 2048
700
  is_master = access_manager.is_master_key(x_access_key)
701
  if is_master:
702
- max_t = 4096
703
  system_prompt += (
704
  " [CRITICAL INSTRUCTION: The user communicating with you is the Master Admin. "
705
- "You must adopt a slightly sassy, highly confident, and sharp-witted persona. "
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.3
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
- # Simple check for SPY drops
873
- ticker = yf.Ticker("SPY")
874
- hist = ticker.history(period="2d")
875
- if len(hist) >= 2:
876
- current = float(hist['Close'].iloc[-1])
877
- prev = float(hist['Close'].iloc[-2])
878
- pct_change = ((current - prev) / prev) * 100
 
 
 
 
 
 
879
 
880
- if pct_change <= -5.0:
881
- access_manager.send_telegram_alert(f"🚨 **MARKET ALERT**\nSPY has dropped by {pct_change:.2f}%!\nCheck the portfolio engine.")
 
 
 
 
 
 
 
 
 
 
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
- curr_idx = synth_prices.index.get_indexer([current_date], method='ffill')[0]
177
- px = synth_prices.iloc[curr_idx][ticker]
 
 
 
 
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
- curr_idx = synth_prices.index.get_indexer([current_date], method='ffill')[0]
188
- px = synth_prices.iloc[curr_idx][ticker]
 
 
 
 
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", "QUANT-ALPHA-99")
 
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", "Ir_yad")
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=42)
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=42)
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}⚠ 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
  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(input_tickers, b, years=years_to_fetch, cfg=self.cfg.model_dump() if hasattr(self.cfg, 'model_dump') else dict(self.cfg))
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 api:app --host 0.0.0.0 --port 8080
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
- cov = returns_df.apply(lambda x: x.cov(benchmark_rets))
607
- var_m = benchmark_rets.var()
608
- betas = cov / var_m if var_m > 0 else pd.Series(1.0, index=returns_df.columns)
 
 
 
 
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
- _rl_items = "".join(f'<li style="color:#f0883e;margin-bottom:4px">{r}</li>' for r in relax_log)
 
 
 
 
 
 
 
 
 
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 &mdash; 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 &mdash; 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> &nbsp;<span style="font-weight:400">β€” weight &times; 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> &nbsp;<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') == 'Model 1'
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 (Out-of-Sample)</p>'
 
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) &#9432;</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) &#9432;</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) &#9432;</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) &#9432;</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
- risk_attr_html = build_risk_attr_html(mvar_series, cvar_components, factor_ret_attr)
 
 
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>&#9888; 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 Forward Metrics (Ex-Ante)</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,7 +273,7 @@
273
  </div>
274
 
275
  <div class="card">
276
- <h3>Historical Performance (Ex-Post)</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>
 
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
- # ── Extract Durations for Constraints ──
 
 
 
 
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 (!adminKey) return;
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
- width: calc(100vw - 2rem) !important;
1390
- right: 1rem !important;
1391
- bottom: 5rem !important;
1392
- max-height: 70vh !important;
 
 
 
 
 
 
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=list(range(n)), n=n,
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 &amp; 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
- if excess_return == 0 or vol == 0:
 
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}