quantvat / src /services /adv_analysis.py
heisbuba's picture
Upload adv_analysis.py
b0da76f verified
Raw History Blame
14.8 kB
import re
import datetime
import html as html_lib
import pandas as pd
from typing import List, Optional, Tuple
from pathlib import Path
from string import Template
# Import our modular components
from ..state import get_user_temp_dir, update_progress, get_pending_files, clear_pending_file
from .utils import now_str, cleanup_after_analysis, safe_float, parse_mc_or_default, parse_mc_or_none
# --- Constants for Reporting ---
CROSS_REPORT_TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "reports" / "cross-market-report.html"
SHARED_TOKENS_CSS = Path(__file__).resolve().parents[1] / "static" / "css" / "shared_tokens.css"
ORIGINAL_MATCHED_HEADERS = ["Ticker", "Spot MrktCap", "Spot Volume", "Spot VTMR", "Futures Volume", "Futures VTMR", "OISS", "Funding Rate"]
ORIGINAL_FUTURES_HEADERS = ["Ticker", "Market Cap", "Volume", "VTMR", "OISS", "Funding Rate"]
ORIGINAL_SPOT_HEADERS = ["Ticker", "MarketCap", "Volume", "VTMR"]
class SignalEngine:
@staticmethod
def _oi_score_and_signal(oi_change: float) -> Tuple[int, str]:
if oi_change > 0.20: return 5, "Strong"
if oi_change > 0.10: return 4, "Bullish"
if oi_change > 0.00: return 3, "Build-Up"
if oi_change > -0.10: return 2, "Weakening"
if oi_change > -0.20: return 1, "Exiting"
return 0, "Exiting"
@staticmethod
def _funding_score_and_signal(funding_val: float) -> Tuple[str, str]:
if funding_val >= 0.05: return "Greed", "oi-strong"
if funding_val > 0.00: return "Bullish", "oi-strong"
if funding_val <= -0.05: return "Extreme Fear", "oi-weak"
if funding_val < 0.00: return "Bearish", "oi-weak"
return "Neutral", ""
@classmethod
def make_oiss(cls, oi_pct: Optional[float]) -> str:
if oi_pct is None or (isinstance(oi_pct, float) and pd.isna(oi_pct)):
return "-"
try:
oi_change = oi_pct / 100
score, signal = cls._oi_score_and_signal(oi_change)
if oi_change > 0: css_class = "oi-strong"
elif oi_change < 0: css_class = "oi-weak"
else: css_class = ""
sign = "+" if oi_change > 0 else ""
if css_class:
return f'<span class="{css_class}">{sign}{oi_change*100:.0f}%</span> {signal}'
return f"{sign}{oi_change*100:.0f}% {signal}"
except Exception:
return "-"
@classmethod
def make_funding_signal(cls, funding_pct: Optional[float]) -> str:
if funding_pct is None or (isinstance(funding_pct, float) and pd.isna(funding_pct)):
return "-"
try:
val = float(funding_pct)
signal_word, css_class = cls._funding_score_and_signal(val)
if css_class:
return f'<span class="{css_class}">{val}%</span> <span style="font-size:0.8em; color:var(--report-muted);">{signal_word}</span>'
return f'{val}% {signal_word}'
except Exception:
return "-"
class DataProcessor:
"""Handles Dataframe loading, merging, and HTML generation."""
@staticmethod
def format_ticker_btn(sym_val) -> str:
"""Helper method isolated to avoid scope issues."""
sym_clean = html_lib.escape(str(sym_val).strip().upper(), quote=True)
return f'<a href="/deep-diver?ticker={sym_clean}" class="ticker-btn">{sym_clean}</a>'
@staticmethod
def format_futures_money(value) -> str:
"""Adds a '$' prefix to a parseable futures market_cap/volume value; leaves n/a bare."""
if pd.isna(value):
return "n/a"
s = str(value).strip()
if s.lower() == "n/a":
return "n/a"
return f"${s}"
@staticmethod
def _caps_compatible(spot_cap, fut_cap, max_ratio: float = 5.0) -> bool:
"""Guards against cross-market ticker collisions."""
spot_val = parse_mc_or_none(spot_cap)
fut_val = parse_mc_or_none(fut_cap)
if spot_val is None or fut_val is None or spot_val <= 0 or fut_val <= 0:
return True
ratio = max(spot_val, fut_val) / min(spot_val, fut_val)
return ratio <= max_ratio
@staticmethod
def load_spot(path: Path) -> pd.DataFrame:
print(f" Parsing Spot File: {path.name}")
try:
if path.suffix == '.html':
df = pd.read_html(str(path), encoding='utf-8')[0]
else:
df = pd.read_csv(path, encoding='utf-8')
df.columns = [c.lower().replace(' ', '_') for c in df.columns]
col_map = {
'ticker': 'ticker',
'symbol': 'ticker',
'vtmr': 'vtmr',
'spot_vtmr': 'vtmr',
'flipping_multiple': 'vtmr',
'market_cap': 'market_cap',
'marketcap': 'market_cap',
'volume_24h': 'volume',
'volume': 'volume'
}
df = df.rename(columns=col_map, errors='ignore')
if 'ticker' not in df.columns:
for col in df.columns:
if 'sym' in col or 'tick' in col or 'tok' in col:
df = df.rename(columns={col: 'ticker'})
break
if 'ticker' in df.columns:
df['ticker'] = df['ticker'].apply(lambda x: str(x).strip().upper())
print(f" Extracted {len(df)} spot tokens")
return df
except Exception as e:
print(f" Spot File Error: {e}")
return pd.DataFrame()
@staticmethod
def _generate_table_html(title: str, df: pd.DataFrame, headers: List[str], df_cols: List[str]) -> str:
if df.empty:
return f'<div class="table-container"><h2>{title}</h2><p>No data found</p></div>'
missing = [c for c in df_cols if c not in df.columns]
df_display = df.copy()
for m in missing:
df_display[m] = ""
df_display = df_display[df_cols]
df_display.columns = headers
SAFE_HTML_HEADERS = {"Ticker", "OISS", "Funding Rate"}
for col in df_display.columns:
if col not in SAFE_HTML_HEADERS:
df_display[col] = df_display[col].apply(lambda v: html_lib.escape(str(v)))
table_html = df_display.to_html(index=False, classes='table', escape=False)
return f'<div class="table-container"><h2>{title}</h2>{table_html}</div>'
@staticmethod
def generate_html_report(futures_df: pd.DataFrame, spot_df: pd.DataFrame) -> Optional[str]:
"""Merges Spot and Futures dataframes and creates the final HTML report."""
if futures_df.empty or spot_df.empty:
return None
futures_df = futures_df.copy()
if 'oi_pct' in futures_df.columns:
futures_df['oiss'] = futures_df['oi_pct'].apply(SignalEngine.make_oiss)
else:
futures_df['oiss'] = "-"
if 'funding_pct' in futures_df.columns:
futures_df['funding'] = futures_df['funding_pct'].apply(SignalEngine.make_funding_signal)
else:
futures_df['funding'] = "-"
valid_futures = futures_df.copy()
try:
if 'vtmr' in valid_futures.columns:
valid_futures['vtmr_display'] = valid_futures['vtmr'].apply(lambda x: f"{x:.2f}x")
except Exception as e:
print(f" Futures display formatting error: {e}")
valid_futures['vtmr_display'] = valid_futures['vtmr']
merged = pd.merge(spot_df, valid_futures, on='ticker', how='inner', suffixes=('_spot', '_fut'))
if not merged.empty and 'market_cap_spot' in merged.columns and 'market_cap_fut' in merged.columns:
cap_ok = merged.apply(
lambda r: DataProcessor._caps_compatible(r.get('market_cap_spot'), r.get('market_cap_fut')),
axis=1
)
mismatched = merged[~cap_ok]
if not mismatched.empty:
print(f" ⚠️ Excluded {len(mismatched)} ticker collision(s) from cross-market merge "
f"(market cap mismatch, likely different tokens sharing a symbol): "
f"{sorted(mismatched['ticker'].unique().tolist())}")
merged = merged[cap_ok].copy()
matched_tickers = set(merged['ticker'])
if 'vtmr_fut' in merged.columns:
merged = merged.sort_values('vtmr_fut', ascending=False)
futures_only = valid_futures[~valid_futures['ticker'].isin(matched_tickers)].copy()
if 'vtmr' in futures_only.columns:
futures_only = futures_only.sort_values('vtmr', ascending=False)
spot_only = spot_df[~spot_df['ticker'].isin(matched_tickers)].copy()
if 'vtmr' in spot_only.columns:
try:
spot_only = spot_only.copy()
spot_only.loc[:, 'sort_val'] = spot_only['vtmr'].astype(str).str.replace('x', '', case=False).astype(float)
spot_only = spot_only.sort_values('sort_val', ascending=False).drop(columns=['sort_val'])
except Exception as e:
print(f" Spot filtering error: {e}")
# Map button formatting via isolated staticmethod
if 'ticker' in merged.columns:
merged['ticker'] = merged['ticker'].apply(DataProcessor.format_ticker_btn)
if 'ticker' in futures_only.columns:
futures_only['ticker'] = futures_only['ticker'].apply(DataProcessor.format_ticker_btn)
if 'ticker' in spot_only.columns:
spot_only['ticker'] = spot_only['ticker'].apply(DataProcessor.format_ticker_btn)
merged_cols = ['ticker', 'market_cap_spot', 'volume_spot', 'vtmr_spot', 'volume_fut', 'vtmr_display', 'oiss', 'funding']
futures_cols = ['ticker', 'market_cap', 'volume', 'vtmr_display', 'oiss', 'funding']
spot_cols = ['ticker', 'market_cap', 'volume', 'vtmr']
tables_body = ""
tables_body += DataProcessor._generate_table_html("Tokens in Both Futures & Spot Markets", merged, ORIGINAL_MATCHED_HEADERS, merged_cols)
tables_body += DataProcessor._generate_table_html("Remaining Futures-Only Tokens", futures_only, ORIGINAL_FUTURES_HEADERS, futures_cols)
tables_body += DataProcessor._generate_table_html("Remaining Spot-Only Tokens", spot_only, ORIGINAL_SPOT_HEADERS, spot_cols)
current_time = now_str("%d-%m-%Y %H:%M:%S")
cheat_sheet_footer = """
<div style="margin-top: 30px; padding: 15px; background: var(--report-guide); border-radius: 8px;">
<h2 style="color: var(--report-head); border-bottom: 2px solid var(--report-accent); padding-bottom: 5px; margin-top: 0;">Guide to Reading the Cross‑Market Report</h2>
<p>For a comprehensive guide on how to interpret the cross‑market report, please visit: <a href="https://www.quantvat.name.ng/2026/08/reading-quantvat-cross-market-report.html" target="_blank">here</a></p>
</div>
"""
template_text = CROSS_REPORT_TEMPLATE.read_text(encoding="utf-8")
final_document = Template(template_text).substitute(
tokens_css=SHARED_TOKENS_CSS.read_text(encoding="utf-8"),
current_time=current_time,
tables_body=tables_body,
cheat_sheet_footer=cheat_sheet_footer,
)
return final_document
def adv_analysis_v5(user_keys, user_id) -> None:
"""Main execution flow for Advanced Analysis."""
print(" Scanning for files to run cross-market analysis")
update_progress(user_id, 10, "Locating Spot and Futures files...", "active")
pending = get_pending_files(user_id)
spot_file = pending.get("spot")
futures_file = pending.get("futures")
if not spot_file or not futures_file:
print(" Required files not found.")
raise FileNotFoundError(" You Need CoinAlyze Futures PDF and Spot Market Data. Kindly Generate Spot Data And Upload Futures PDF First.")
update_progress(user_id, 40, "Loading Futures and Spot data...", "active")
spot_df = DataProcessor.load_spot(spot_file)
try:
futures_df = pd.read_csv(futures_file, dtype={'ticker': str}, encoding='utf-8')
print(f" Retrieved Futures CSV: {futures_file.name}")
except Exception as e:
print(f" Futures CSV Error: {e}")
futures_df = pd.DataFrame()
if not futures_df.empty:
for col in ('market_cap', 'volume'):
if col in futures_df.columns:
futures_df[col] = futures_df[col].apply(DataProcessor.format_futures_money)
settings = user_keys.get("engine_settings", {}) if user_keys else {}
MIN_F_VTMR = safe_float(settings.get('min_f_vtmr'), 0.5)
MAX_F_VTMR = safe_float(settings.get('max_f_vtmr'), 399.0)
MIN_F_MC = parse_mc_or_default(settings.get('min_f_mc'), 0.0)
MAX_F_MC = parse_mc_or_default(settings.get('max_f_mc'), float('inf'))
if not futures_df.empty and 'vtmr' in futures_df.columns:
before_count = len(futures_df)
futures_df = futures_df[(futures_df['vtmr'] >= MIN_F_VTMR) & (futures_df['vtmr'] <= MAX_F_VTMR)]
print(f" Extracted {len(futures_df)} futures tokens out of {before_count} with VTMR filter applied")
if not futures_df.empty and 'market_cap' in futures_df.columns:
before_count = len(futures_df)
mc_numeric = futures_df['market_cap'].apply(parse_mc_or_none)
# Rows with missing/unparseable market cap pass through unfiltered (VTMR filter already vetted them)
in_range = mc_numeric.isna() | ((mc_numeric >= MIN_F_MC) & (mc_numeric <= MAX_F_MC))
futures_df = futures_df[in_range]
print(f" Then extracted {len(futures_df)} futures tokens out of {before_count} with MC filter applied")
update_progress(user_id, 65, "Merging cross-market signals...", "active")
html_content = DataProcessor.generate_html_report(futures_df, spot_df)
if not html_content:
print(" No data to generate report")
raise ValueError("No matching data between spot and futures files — check both sources.")
update_progress(user_id, 85, "Saving report...", "active")
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
html_file = get_user_temp_dir(user_id) / f"cross-market-analysis-{timestamp}.html"
try:
html_file.write_text(html_content, encoding="utf-8")
except Exception as e:
print(f" Report save failed: {e}")
raise RuntimeError(f"Could not save the report: {e}")
print(f" Report saved as: {html_file.name}")
cleanup_after_analysis(spot_file, futures_file, keep_spot=True)
clear_pending_file(user_id, "futures")
print(" Analysis completed!")