File size: 14,798 Bytes
d96399a
c554446
2440232
d96399a
b76820b
 
b0da76f
346305b
b76820b
c87b623
84574af
346305b
 
b0da76f
 
346305b
 
 
cade15b
346305b
139cb64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b0da76f
139cb64
 
 
 
 
346305b
b76820b
c8194fa
ef860b4
 
 
 
d2804b9
ef860b4
46085a5
 
 
 
 
 
 
 
 
 
c8194fa
 
ef860b4
8b68f73
 
c8194fa
 
 
 
 
346305b
 
 
 
 
cade15b
346305b
cade15b
346305b
 
 
 
 
139cb64
346305b
 
 
 
 
 
 
 
 
 
 
15356ea
346305b
 
 
 
 
cade15b
51a4f9a
346305b
 
 
 
 
b76820b
346305b
 
 
 
 
 
 
 
 
 
283629d
a9cfd25
7957d12
 
ef860b4
7957d12
346305b
 
 
 
 
 
 
 
139cb64
 
 
 
 
73a257a
346305b
139cb64
 
 
 
 
346305b
 
 
 
 
b34c551
346305b
 
 
c8194fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346305b
 
c8194fa
 
346305b
 
c8194fa
 
346305b
 
 
 
4348a44
e602f8c
346305b
 
a9cfd25
ef860b4
a9cfd25
ef860b4
a9cfd25
ef860b4
a9cfd25
ef860b4
346305b
 
 
 
 
ef860b4
 
 
 
346305b
 
c554446
b0da76f
 
3db4d87
e84f453
346305b
ef860b4
b0da76f
 
 
 
 
 
 
ef860b4
346305b
be1a45a
346305b
0c45b0c
e52590f
346305b
c87b623
 
 
346305b
 
b76820b
346305b
e52590f
c49c811
ba2076b
 
0c45b0c
ba2076b
 
 
b34c551
46085a5
 
 
 
 
b34c551
 
 
8b68f73
 
b34c551
 
 
 
0c45b0c
11f78d9
 
 
8b68f73
11f78d9
 
 
7abf01e
346305b
e52590f
346305b
c49c811
346305b
c49c811
 
c554446
 
 
d2391fb
c554446
d2391fb
c554446
 
0c45b0c
a6dd1eb
c49c811
46085a5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
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!")