Spaces:
Sleeping
Sleeping
Update app/yahooinfo.py
Browse files- app/yahooinfo.py +317 -1
app/yahooinfo.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# ==============================
|
| 2 |
# Imports
|
| 3 |
# ==============================
|
| 4 |
import yfinance as yf
|
|
@@ -315,3 +315,319 @@ def fetch_info(symbol):
|
|
| 315 |
return html
|
| 316 |
except Exception:
|
| 317 |
return f"<pre>{traceback.format_exc()}</pre>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'''# ==============================
|
| 2 |
# Imports
|
| 3 |
# ==============================
|
| 4 |
import yfinance as yf
|
|
|
|
| 315 |
return html
|
| 316 |
except Exception:
|
| 317 |
return f"<pre>{traceback.format_exc()}</pre>"
|
| 318 |
+
'''
|
| 319 |
+
# ==============================
|
| 320 |
+
# Imports
|
| 321 |
+
# ==============================
|
| 322 |
+
import yfinance as yf
|
| 323 |
+
import pandas as pd
|
| 324 |
+
import traceback
|
| 325 |
+
from datetime import datetime, timezone
|
| 326 |
+
|
| 327 |
+
from .persist import exists, load, save
|
| 328 |
+
|
| 329 |
+
# ==============================
|
| 330 |
+
# Icons
|
| 331 |
+
# ==============================
|
| 332 |
+
MAIN_ICONS = {
|
| 333 |
+
"Price / Volume": "📈",
|
| 334 |
+
"Fundamentals": "📊",
|
| 335 |
+
"Trend": "📈",
|
| 336 |
+
"Signals": "🧠",
|
| 337 |
+
"Company Profile": "🏢",
|
| 338 |
+
"Management": "👔",
|
| 339 |
+
"VWAP": "📌",
|
| 340 |
+
"IPO": "🚀"
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
# ==============================
|
| 344 |
+
# Short names
|
| 345 |
+
# ==============================
|
| 346 |
+
SHORT_NAMES = {
|
| 347 |
+
"regularMarketPrice": "Price",
|
| 348 |
+
"regularMarketChange": "Chg",
|
| 349 |
+
"regularMarketChangePercent": "Chg%",
|
| 350 |
+
"regularMarketPreviousClose": "Prev",
|
| 351 |
+
"regularMarketOpen": "Open",
|
| 352 |
+
"regularMarketDayHigh": "High",
|
| 353 |
+
"regularMarketDayLow": "Low",
|
| 354 |
+
"regularMarketVolume": "Vol",
|
| 355 |
+
"averageDailyVolume10Day": "Avg Vol 10D",
|
| 356 |
+
"averageDailyVolume3Month": "Avg Vol 3M",
|
| 357 |
+
"fiftyDayAverage": "50DMA",
|
| 358 |
+
"twoHundredDayAverage": "200DMA",
|
| 359 |
+
"fiftyTwoWeekLow": "52W Low",
|
| 360 |
+
"fiftyTwoWeekHigh": "52W High",
|
| 361 |
+
"mostRecentQuarter":"Recent Q",
|
| 362 |
+
"lastFiscalYearEnd":"FY End",
|
| 363 |
+
"vwap":"VWAP",
|
| 364 |
+
"dailyGapPercent":"Gap%",
|
| 365 |
+
"dailyRangePercent":"Range%",
|
| 366 |
+
"firstTradeDate":"IPO Date"
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
# ==============================
|
| 370 |
+
# Price / Volume Sub-Groups
|
| 371 |
+
# ==============================
|
| 372 |
+
PRICE_VOLUME_GROUPS = {
|
| 373 |
+
"Market Price": ["Price","Chg","Chg%","Prev","Open","firstTradeDate"],
|
| 374 |
+
"Intraday Range": ["High","Low","dailyRangePercent"],
|
| 375 |
+
"Volume & Liquidity": ["Vol","Avg Vol 10D","Avg Vol 3M"],
|
| 376 |
+
"Moving Averages": ["50DMA","200DMA","vs 50DMA","vs 200DMA"],
|
| 377 |
+
"52W Range": ["52W Low","52W High","52W Pos"],
|
| 378 |
+
"VWAP & Gap": ["VWAP","dailyGapPercent"]
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
# ==============================
|
| 382 |
+
# Noise keys
|
| 383 |
+
# ==============================
|
| 384 |
+
NOISE_KEYS = {
|
| 385 |
+
"maxAge","priceHint","triggerable",
|
| 386 |
+
"customPriceAlertConfidence",
|
| 387 |
+
"sourceInterval","exchangeDataDelayedBy",
|
| 388 |
+
"esgPopulated"
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
# ==============================
|
| 392 |
+
# Low-level Yahoo fetch
|
| 393 |
+
# ==============================
|
| 394 |
+
def yfinfo(symbol):
|
| 395 |
+
try:
|
| 396 |
+
t = yf.Ticker(symbol + ".NS")
|
| 397 |
+
info = t.info
|
| 398 |
+
return info if isinstance(info, dict) else {}
|
| 399 |
+
except Exception as e:
|
| 400 |
+
return {"__error__": str(e)}
|
| 401 |
+
|
| 402 |
+
# ==============================
|
| 403 |
+
# Formatting
|
| 404 |
+
# ==============================
|
| 405 |
+
def human_number(n):
|
| 406 |
+
try:
|
| 407 |
+
n = float(n)
|
| 408 |
+
if abs(n) >= 1e7: return f"{n/1e7:.2f}Cr"
|
| 409 |
+
if abs(n) >= 1e5: return f"{n/1e5:.2f}L"
|
| 410 |
+
if abs(n) >= 1e3: return f"{n/1e3:.2f}K"
|
| 411 |
+
return f"{n:,.2f}"
|
| 412 |
+
except:
|
| 413 |
+
return str(n)
|
| 414 |
+
|
| 415 |
+
DATE_KEYWORDS = ("date", "time", "timestamp", "fiscal", "quarter","earnings","dividend","firstTradeDate")
|
| 416 |
+
def looks_like_unix_ts(v):
|
| 417 |
+
try:
|
| 418 |
+
v = int(v)
|
| 419 |
+
return (946684800 <= v <= 4102444800 or 946684800000 <= v <= 4102444800000)
|
| 420 |
+
except:
|
| 421 |
+
return False
|
| 422 |
+
def unix_to_dt(v):
|
| 423 |
+
v = int(v)
|
| 424 |
+
if v > 10**12: v //= 1000
|
| 425 |
+
return datetime.fromtimestamp(v, tz=timezone.utc)
|
| 426 |
+
def fy_quarter_label(dt):
|
| 427 |
+
y, m = dt.year, dt.month
|
| 428 |
+
if m >= 4:
|
| 429 |
+
fy = y + 1
|
| 430 |
+
q = (m - 1)//3 + 1
|
| 431 |
+
else:
|
| 432 |
+
fy = y
|
| 433 |
+
q = (m + 8)//3
|
| 434 |
+
return f"Q{q} FY{str(fy)[-2:]}"
|
| 435 |
+
def format_value(k, v):
|
| 436 |
+
lk = k.lower()
|
| 437 |
+
# Date / Quarter
|
| 438 |
+
if isinstance(v,(int,float)) and looks_like_unix_ts(v):
|
| 439 |
+
if any(x in lk for x in DATE_KEYWORDS):
|
| 440 |
+
dt = unix_to_dt(v)
|
| 441 |
+
if "quarter" in lk:
|
| 442 |
+
return fy_quarter_label(dt)
|
| 443 |
+
return dt.strftime("%d %b %Y")
|
| 444 |
+
# Numbers
|
| 445 |
+
if isinstance(v,(int,float)):
|
| 446 |
+
cls = "pos" if v>0 else "neg" if v<0 else ""
|
| 447 |
+
if "percent" in lk:
|
| 448 |
+
return f'<span class="{cls}">{v:.2f}%</span>'
|
| 449 |
+
if any(x in lk for x in ["marketcap","revenue","income","value","cap","enterprise"]):
|
| 450 |
+
return f'<span class="{cls}">₹{human_number(v)}</span>'
|
| 451 |
+
return f'<span class="{cls}">{human_number(v)}</span>'
|
| 452 |
+
# Strings
|
| 453 |
+
if isinstance(v,str) and len(v)>80:
|
| 454 |
+
return f'<div style="font-size:12px;">{v}</div>'
|
| 455 |
+
return v
|
| 456 |
+
|
| 457 |
+
# ==============================
|
| 458 |
+
# HTML Helpers
|
| 459 |
+
# ==============================
|
| 460 |
+
def column_layout(html):
|
| 461 |
+
return f"""
|
| 462 |
+
<style>
|
| 463 |
+
.grid{{display:grid;gap:10px;grid-template-columns:repeat(3,1fr);}}
|
| 464 |
+
@media(max-width:1024px){{.grid{{grid-template-columns:repeat(2,1fr);}}}}
|
| 465 |
+
@media(max-width:640px){{.grid{{grid-template-columns:1fr;}}}}
|
| 466 |
+
.pos{{color:#0a7d32;font-weight:600;}}
|
| 467 |
+
.neg{{color:#b00020;font-weight:600;}}
|
| 468 |
+
.alert{{color:#d97706;font-weight:600;}}
|
| 469 |
+
</style>
|
| 470 |
+
<div class="grid">{html}</div>
|
| 471 |
+
"""
|
| 472 |
+
def html_card(title,body,mini=False):
|
| 473 |
+
font = "12px" if mini else "14px"
|
| 474 |
+
pad = "6px" if mini else "10px"
|
| 475 |
+
return f"""
|
| 476 |
+
<div style="background:#e6f0fa;border:1px solid #a3c0e0;border-radius:8px;padding:{pad};
|
| 477 |
+
font-size:{font};margin-bottom:6px;">
|
| 478 |
+
<div style="font-weight:600;margin-bottom:6px;">{title}</div>{body}
|
| 479 |
+
</div>
|
| 480 |
+
"""
|
| 481 |
+
def make_table(df):
|
| 482 |
+
return "".join(
|
| 483 |
+
f"""<div style="display:flex;justify-content:space-between;border-bottom:1px dashed #bcd0ea;padding:2px 0;">
|
| 484 |
+
<span>{r.Field}</span><span>{r.Value}</span></div>"""
|
| 485 |
+
for r in df.itertuples()
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
# ==============================
|
| 489 |
+
# Data Helpers
|
| 490 |
+
# ==============================
|
| 491 |
+
def build_df_from_dict(data):
|
| 492 |
+
rows = [(SHORT_NAMES.get(k,k[:16]), format_value(k,v)) for k,v in data.items() if k not in NOISE_KEYS]
|
| 493 |
+
return pd.DataFrame(rows,columns=["Field","Value"])
|
| 494 |
+
|
| 495 |
+
def resolve_duplicates(data):
|
| 496 |
+
DUP = {
|
| 497 |
+
"price":["regularMarketPrice","currentPrice"],
|
| 498 |
+
"prev":["regularMarketPreviousClose","previousClose"],
|
| 499 |
+
"open":["regularMarketOpen","open"],
|
| 500 |
+
"high":["regularMarketDayHigh","dayHigh"],
|
| 501 |
+
"low":["regularMarketDayLow","dayLow"],
|
| 502 |
+
"volume":["regularMarketVolume","volume"]
|
| 503 |
+
}
|
| 504 |
+
resolved, used = {}, set()
|
| 505 |
+
for keys in DUP.values():
|
| 506 |
+
for k in keys:
|
| 507 |
+
if k in data:
|
| 508 |
+
resolved[k] = data[k]
|
| 509 |
+
used.update(keys)
|
| 510 |
+
break
|
| 511 |
+
for k,v in data.items():
|
| 512 |
+
if k not in used:
|
| 513 |
+
resolved[k] = v
|
| 514 |
+
return resolved
|
| 515 |
+
|
| 516 |
+
def classify(k,v):
|
| 517 |
+
lk = k.lower()
|
| 518 |
+
if k=="companyOfficers": return "management"
|
| 519 |
+
if any(x in lk for x in ["pe","pb","roe","roa","margin","debt","revenue","profit","eps","cap"]):
|
| 520 |
+
return "fundamental"
|
| 521 |
+
if isinstance(v,(int,float)): return "price_volume"
|
| 522 |
+
if isinstance(v,str) and len(v)>80: return "long_text"
|
| 523 |
+
return "profile"
|
| 524 |
+
|
| 525 |
+
def group_info(info):
|
| 526 |
+
g = {"price_volume":{}, "fundamental":{}, "profile":{}, "management":{}, "long_text":{}}
|
| 527 |
+
for k,v in info.items():
|
| 528 |
+
if k in NOISE_KEYS or v in [None,"",[],{}]: continue
|
| 529 |
+
g[classify(k,v)][k] = v
|
| 530 |
+
return g
|
| 531 |
+
|
| 532 |
+
def split_df(df):
|
| 533 |
+
n = len(df)
|
| 534 |
+
cols = 1 if n<=6 else 2 if n<=14 else 3
|
| 535 |
+
size = (n+cols-1)//cols
|
| 536 |
+
return [df.iloc[i:i+size] for i in range(0,n,size)]
|
| 537 |
+
|
| 538 |
+
# ==============================
|
| 539 |
+
# Derived Metrics & Signals
|
| 540 |
+
# ==============================
|
| 541 |
+
def build_price_volume_derived(info):
|
| 542 |
+
out={}
|
| 543 |
+
price=info.get("regularMarketPrice")
|
| 544 |
+
dma50=info.get("fiftyDayAverage")
|
| 545 |
+
dma200=info.get("twoHundredDayAverage")
|
| 546 |
+
low52=info.get("fiftyTwoWeekLow")
|
| 547 |
+
high52=info.get("fiftyTwoWeekHigh")
|
| 548 |
+
if price and dma50: out["vs 50DMA"]="Above ↑" if price>dma50 else "Below ↓"
|
| 549 |
+
if price and dma200: out["vs 200DMA"]="Above ↑" if price>dma200 else "Below ↓"
|
| 550 |
+
if price and low52 and high52 and high52!=low52: out["52W Pos"]=f"{(price-low52)/(high52-low52)*100:.1f}%"
|
| 551 |
+
prev=info.get("regularMarketPreviousClose")
|
| 552 |
+
high=info.get("regularMarketDayHigh")
|
| 553 |
+
low=info.get("regularMarketDayLow")
|
| 554 |
+
if price and prev: out["dailyGapPercent"]=(price-prev)/prev*100
|
| 555 |
+
if high and low and price: out["dailyRangePercent"]=(high-low)/price*100
|
| 556 |
+
vol=info.get("regularMarketVolume")
|
| 557 |
+
if vol and price: out["VWAP"]=price
|
| 558 |
+
if info.get("firstTradeDate"): out["firstTradeDate"]=info.get("firstTradeDate")
|
| 559 |
+
return out
|
| 560 |
+
|
| 561 |
+
def build_smart_signals(info):
|
| 562 |
+
rows=[]
|
| 563 |
+
pe=info.get("trailingPE")
|
| 564 |
+
roe=info.get("returnOnEquity")
|
| 565 |
+
debt=info.get("debtToEquity")
|
| 566 |
+
price=info.get("regularMarketPrice")
|
| 567 |
+
dma50=info.get("fiftyDayAverage")
|
| 568 |
+
dma200=info.get("twoHundredDayAverage")
|
| 569 |
+
# Alerts
|
| 570 |
+
if pe: rows.append(("Valuation",f'<span class="alert">{"Expensive" if pe>35 else "Cheap" if pe<15 else "Fair"}</span>'))
|
| 571 |
+
if roe: rows.append(("Quality",f'<span class="alert">{"High" if roe>0.18 else "Average"}</span>'))
|
| 572 |
+
if debt: rows.append(("Balance Sheet",f'<span class="alert">{"Weak" if debt>1 else "Healthy"}</span>'))
|
| 573 |
+
if price and dma50 and dma200:
|
| 574 |
+
trend = "Bullish" if price>dma50>dma200 else "Bearish" if price<dma50<dma200 else "Neutral"
|
| 575 |
+
rows.append(("Momentum",f'<span class="alert">{trend}</span>'))
|
| 576 |
+
return pd.DataFrame(rows,columns=["Field","Value"])
|
| 577 |
+
|
| 578 |
+
# ==============================
|
| 579 |
+
# Build Price/Volume Section
|
| 580 |
+
# ==============================
|
| 581 |
+
def build_price_volume_section(info,pv_data):
|
| 582 |
+
df=build_df_from_dict(pv_data)
|
| 583 |
+
derived=build_price_volume_derived(info)
|
| 584 |
+
if derived: df=pd.concat([df,pd.DataFrame(derived.items(),columns=["Field","Value"])],ignore_index=True)
|
| 585 |
+
cards=""
|
| 586 |
+
for title,fields in PRICE_VOLUME_GROUPS.items():
|
| 587 |
+
sub=df[df["Field"].isin(fields)]
|
| 588 |
+
if not sub.empty: cards+=html_card(title,make_table(sub),mini=True)
|
| 589 |
+
trend_df=df[df["Field"].isin(["vs 50DMA","vs 200DMA","52W Pos"])]
|
| 590 |
+
if not trend_df.empty: cards+=html_card("Trend & Momentum",make_table(trend_df),mini=True)
|
| 591 |
+
signals=build_smart_signals(info)
|
| 592 |
+
if not signals.empty: cards+=html_card("Smart Signals",make_table(signals),mini=True)
|
| 593 |
+
return column_layout(cards)
|
| 594 |
+
|
| 595 |
+
# ==============================
|
| 596 |
+
# Main Function
|
| 597 |
+
# ==============================
|
| 598 |
+
def fetch_info(symbol):
|
| 599 |
+
key=f"info_{symbol}"
|
| 600 |
+
if exists(key,"html"):
|
| 601 |
+
cached=load(key,"html")
|
| 602 |
+
if cached: return cached
|
| 603 |
+
try:
|
| 604 |
+
info=yfinfo(symbol)
|
| 605 |
+
if "__error__" in info: return "No data"
|
| 606 |
+
groups=group_info(info)
|
| 607 |
+
html=""
|
| 608 |
+
# Price / Volume
|
| 609 |
+
pv=resolve_duplicates(groups["price_volume"])
|
| 610 |
+
if pv: html+=html_card(f"{MAIN_ICONS['Price / Volume']} Price / Volume",build_price_volume_section(info,pv))
|
| 611 |
+
# Fundamentals
|
| 612 |
+
if groups["fundamental"]:
|
| 613 |
+
df=build_df_from_dict(groups["fundamental"])
|
| 614 |
+
html+=html_card(f"{MAIN_ICONS['Fundamentals']} Fundamentals",
|
| 615 |
+
column_layout("".join(html_card("Fundamentals",make_table(c),mini=True) for c in split_df(df))))
|
| 616 |
+
# Company Profile
|
| 617 |
+
if groups["profile"]:
|
| 618 |
+
df=build_df_from_dict(groups["profile"])
|
| 619 |
+
html+=html_card(f"{MAIN_ICONS['Company Profile']} Company Profile",
|
| 620 |
+
column_layout("".join(html_card("Profile",make_table(c),mini=True) for c in split_df(df))))
|
| 621 |
+
# Management
|
| 622 |
+
if groups["management"].get("companyOfficers"):
|
| 623 |
+
cards=""
|
| 624 |
+
for o in groups["management"]["companyOfficers"]:
|
| 625 |
+
cards+=html_card(o.get("name",""),o.get("title",""),mini=True)
|
| 626 |
+
html+=html_card(f"{MAIN_ICONS['Management']} Management",column_layout(cards))
|
| 627 |
+
# Long Text
|
| 628 |
+
for k,v in groups["long_text"].items():
|
| 629 |
+
html+=html_card(SHORT_NAMES.get(k,k[:16]),v)
|
| 630 |
+
save(key,html,"html")
|
| 631 |
+
return html
|
| 632 |
+
except Exception:
|
| 633 |
+
return f"<pre>{traceback.format_exc()}</pre>"
|