heisbuba commited on
Commit
b76820b
·
verified ·
1 Parent(s): d96399a

Upload 2 files

Browse files
src/services/analysis.py CHANGED
@@ -1,12 +1,13 @@
1
- import datetime
2
  import re
3
- from pathlib import Path
4
- from typing import List, Optional, Tuple
5
-
6
  import pandas as pd
 
 
7
 
 
8
  from ..state import get_user_temp_dir
9
- from .utils import cleanup_after_analysis, convert_html_to_pdf, now_str
 
10
 
11
  # --- Constants for Reporting ---
12
  ORIGINAL_HTML_STYLE = """
@@ -28,77 +29,8 @@ ORIGINAL_MATCHED_HEADERS = ["Ticker", "Spot MrktCap", "Spot Volume", "Spot VTMR"
28
  ORIGINAL_FUTURES_HEADERS = ["Ticker", "Market Cap", "Volume", "VTMR", "OISS", "Funding Rate"]
29
  ORIGINAL_SPOT_HEADERS = ["Ticker", "MarketCap", "Volume", "VTMR"]
30
 
31
-
32
- class SignalEngine:
33
- """Calculates OISS and Funding signals and formats them into HTML spans."""
34
-
35
- @staticmethod
36
- def _oi_score_and_signal(oi_change: float) -> Tuple[int, str]:
37
- if oi_change > 0.20:
38
- return 5, "Strong"
39
- if oi_change > 0.10:
40
- return 4, "Bullish"
41
- if oi_change > 0.00:
42
- return 3, "Build-Up"
43
- if oi_change > -0.10:
44
- return 2, "Weakening"
45
- if oi_change > -0.20:
46
- return 1, "Exiting"
47
- return 0, "Exiting"
48
-
49
- @staticmethod
50
- def _funding_score_and_signal(funding_val: float) -> Tuple[str, str]:
51
- if funding_val >= 0.05:
52
- return "Greed", "oi-strong"
53
- if funding_val > 0.00:
54
- return "Bullish", "oi-strong"
55
- if funding_val <= -0.05:
56
- return "Extreme Fear", "oi-weak"
57
- if funding_val < 0.00:
58
- return "Bearish", "oi-weak"
59
- return "Neutral", ""
60
-
61
- @classmethod
62
- def make_oiss(cls, oi_percent_str: str) -> str:
63
- if not oi_percent_str or str(oi_percent_str) in ['-', 'N/A', 'nan']:
64
- return "-"
65
- val = str(oi_percent_str).replace("%", "").strip()
66
- try:
67
- oi_change = float(val) / 100
68
- score, signal = cls._oi_score_and_signal(oi_change)
69
-
70
- if oi_change > 0:
71
- css_class = "oi-strong"
72
- elif oi_change < 0:
73
- css_class = "oi-weak"
74
- else:
75
- css_class = ""
76
-
77
- sign = "+" if oi_change > 0 else ""
78
- if css_class:
79
- return f'<span class="{css_class}">{sign}{oi_change*100:.0f}%</span> {signal}'
80
- return f"{sign}{oi_change*100:.0f}% {signal}"
81
- except Exception:
82
- return "-"
83
-
84
- @classmethod
85
- def make_funding_signal(cls, funding_str: str) -> str:
86
- if not funding_str or str(funding_str) in ['-', 'N/A', 'nan']:
87
- return "-"
88
- try:
89
- val = float(str(funding_str).replace('%', '').strip())
90
- signal_word, css_class = cls._funding_score_and_signal(val)
91
-
92
- if css_class:
93
- return f'<span class="{css_class}">{val}%</span> <span style="font-size:0.8em; color:#7f8c8d;">{signal_word}</span>'
94
- return f'{val}% {signal_word}'
95
- except Exception:
96
- return str(funding_str)
97
-
98
-
99
  class FileScanner:
100
- """Locates the latest Spot data and parsed Futures CSV files in the USER directory."""
101
-
102
  @staticmethod
103
  def find_files(user_id) -> Tuple[Optional[Path], Optional[Path]]:
104
  spot_file: Optional[Path] = None
@@ -108,14 +40,16 @@ class FileScanner:
108
  if not user_dir.exists():
109
  return None, None
110
 
 
111
  today = datetime.datetime.now().date()
 
 
112
  today_files = []
113
-
114
  for f in user_dir.iterdir():
115
  if f.is_file():
116
  try:
117
  file_time = datetime.datetime.fromtimestamp(f.stat().st_mtime)
118
- if file_time.date() == today:
119
  today_files.append(f)
120
  except Exception:
121
  continue
@@ -123,11 +57,12 @@ class FileScanner:
123
  if not today_files:
124
  return None, None
125
 
 
126
  files = sorted(today_files, key=lambda x: x.stat().st_mtime, reverse=True)
127
 
128
  for f in files:
129
  name = f.name.lower()
130
- if not futures_file and f.suffix == ".csv" and "futures" in name:
131
  futures_file = f
132
  elif not spot_file and f.suffix in [".csv", ".html"] and "spot" in name:
133
  spot_file = f
@@ -137,14 +72,14 @@ class FileScanner:
137
 
138
  return spot_file, futures_file
139
 
140
-
141
  class DataProcessor:
142
- """Handles Dataframe loading, merging, signal generation, and HTML generation."""
143
-
144
  @staticmethod
145
  def load_spot(path: Path) -> pd.DataFrame:
146
  print(f" Parsing Spot File: {path.name}")
147
  try:
 
148
  if path.suffix == '.html':
149
  df = pd.read_html(str(path), encoding='utf-8')[0]
150
  else:
@@ -154,7 +89,7 @@ class DataProcessor:
154
  col_map = {
155
  'ticker': 'ticker',
156
  'symbol': 'ticker',
157
- 'vtmr': 'vtmr',
158
  'spot_vtmr': 'vtmr',
159
  'flipping_multiple': 'vtmr',
160
  'market_cap': 'market_cap',
@@ -165,12 +100,14 @@ class DataProcessor:
165
 
166
  df = df.rename(columns=col_map, errors='ignore')
167
 
 
168
  if 'ticker' not in df.columns:
169
- for col in df.columns:
170
  if 'sym' in col or 'tick' in col or 'tok' in col:
171
  df = df.rename(columns={col: 'ticker'})
172
  break
173
 
 
174
  if 'ticker' in df.columns:
175
  df['ticker'] = df['ticker'].apply(lambda x: str(x).strip().upper())
176
 
@@ -179,25 +116,7 @@ class DataProcessor:
179
  except Exception as e:
180
  print(f" Spot File Error: {e}")
181
  return pd.DataFrame()
182
-
183
- @staticmethod
184
- def load_futures(path: Path) -> pd.DataFrame:
185
- """Loads raw parsed Futures CSV and formats OISS and Funding signals."""
186
- print(f" Parsing Futures CSV: {path.name}")
187
- try:
188
- df = pd.read_csv(path, encoding='utf-8')
189
- if df.empty:
190
- return pd.DataFrame()
191
-
192
- df['oiss'] = df['oi_pct'].apply(SignalEngine.make_oiss)
193
- df['funding'] = df['funding'].apply(SignalEngine.make_funding_signal)
194
-
195
- print(f" Loaded {len(df)} futures tokens from CSV")
196
- return df
197
- except Exception as e:
198
- print(f" Futures CSV Error: {e}")
199
- return pd.DataFrame()
200
-
201
  @staticmethod
202
  def _generate_table_html(title: str, df: pd.DataFrame, headers: List[str], df_cols: List[str]) -> str:
203
  if df.empty:
@@ -208,6 +127,7 @@ class DataProcessor:
208
  df_display[m] = ""
209
  df_display = df_display[df_cols]
210
  df_display.columns = headers
 
211
  table_html = df_display.to_html(index=False, classes='table', escape=False)
212
  return f'<div class="table-container"><h2>{title}</h2>{table_html}</div>'
213
 
@@ -229,6 +149,7 @@ class DataProcessor:
229
  print(f" Futures high-quality filtering error: {e}")
230
  valid_futures['vtmr_display'] = valid_futures['vtmr']
231
 
 
232
  merged = pd.merge(spot_df, valid_futures, on='ticker', how='inner', suffixes=('_spot', '_fut'))
233
  if 'vtmr_fut' in merged.columns:
234
  merged = merged.sort_values('vtmr_fut', ascending=False)
@@ -315,23 +236,25 @@ class DataProcessor:
315
  """
316
  return html
317
 
318
-
319
  def crypto_analysis_v4(user_keys, user_id) -> None:
320
  """Main execution flow for Advanced Analysis."""
321
  print(" ADVANCED QUANT CRYPTO VOLUME ANALYSIS")
322
- print(" Scanning for Parsed Futures CSV and Spot CSV/HTML files")
323
  print(" " + "=" * 50)
324
 
 
325
  spot_file, futures_file = FileScanner.find_files(user_id)
326
  if not spot_file or not futures_file:
327
  print(" Required files not found.")
328
- raise FileNotFoundError(" You Need Spot Market Data and Parsed Futures CSV Data. Kindly Generate Spot Data And Parse Futures PDF First.")
329
 
330
- futures_df = DataProcessor.load_futures(futures_file)
 
331
  spot_df = DataProcessor.load_spot(spot_file)
332
 
333
  html_content = DataProcessor.generate_html_report(futures_df, spot_df)
334
  if html_content:
 
335
  pdf_path = convert_html_to_pdf(html_content, user_id)
336
 
337
  if pdf_path:
 
 
1
  import re
2
+ import datetime
 
 
3
  import pandas as pd
4
+ from typing import List, Optional, Tuple
5
+ from pathlib import Path
6
 
7
+ # Import our modular components
8
  from ..state import get_user_temp_dir
9
+ from .utils import now_str, convert_html_to_pdf, cleanup_after_analysis
10
+ from .futures_engine import PDFParser
11
 
12
  # --- Constants for Reporting ---
13
  ORIGINAL_HTML_STYLE = """
 
29
  ORIGINAL_FUTURES_HEADERS = ["Ticker", "Market Cap", "Volume", "VTMR", "OISS", "Funding Rate"]
30
  ORIGINAL_SPOT_HEADERS = ["Ticker", "MarketCap", "Volume", "VTMR"]
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  class FileScanner:
33
+ """Locates the latest Spot and Futures data files in the USER directory."""
 
34
  @staticmethod
35
  def find_files(user_id) -> Tuple[Optional[Path], Optional[Path]]:
36
  spot_file: Optional[Path] = None
 
40
  if not user_dir.exists():
41
  return None, None
42
 
43
+ # Get today's date for filtering
44
  today = datetime.datetime.now().date()
45
+
46
+ # Filter for today's files only
47
  today_files = []
 
48
  for f in user_dir.iterdir():
49
  if f.is_file():
50
  try:
51
  file_time = datetime.datetime.fromtimestamp(f.stat().st_mtime)
52
+ if file_time.date() == today: # Only use today's files
53
  today_files.append(f)
54
  except Exception:
55
  continue
 
57
  if not today_files:
58
  return None, None
59
 
60
+ # Sort by modification time (newest first)
61
  files = sorted(today_files, key=lambda x: x.stat().st_mtime, reverse=True)
62
 
63
  for f in files:
64
  name = f.name.lower()
65
+ if not futures_file and f.suffix == ".pdf" and "futures" in name:
66
  futures_file = f
67
  elif not spot_file and f.suffix in [".csv", ".html"] and "spot" in name:
68
  spot_file = f
 
72
 
73
  return spot_file, futures_file
74
 
 
75
  class DataProcessor:
76
+ """Handles Dataframe loading, merging, and HTML generation."""
77
+
78
  @staticmethod
79
  def load_spot(path: Path) -> pd.DataFrame:
80
  print(f" Parsing Spot File: {path.name}")
81
  try:
82
+ # Explicit UTF-8 for Unicode preservation
83
  if path.suffix == '.html':
84
  df = pd.read_html(str(path), encoding='utf-8')[0]
85
  else:
 
89
  col_map = {
90
  'ticker': 'ticker',
91
  'symbol': 'ticker',
92
+ 'vtmr': 'vtmr', # <--- Ensures VTMR isn't blank
93
  'spot_vtmr': 'vtmr',
94
  'flipping_multiple': 'vtmr',
95
  'market_cap': 'market_cap',
 
100
 
101
  df = df.rename(columns=col_map, errors='ignore')
102
 
103
+ # Normalize ticker column (Find it if it's missing)
104
  if 'ticker' not in df.columns:
105
+ for col in df.columns:
106
  if 'sym' in col or 'tick' in col or 'tok' in col:
107
  df = df.rename(columns={col: 'ticker'})
108
  break
109
 
110
+ # Unicode-safe cleaning (Protects Chinese characters)
111
  if 'ticker' in df.columns:
112
  df['ticker'] = df['ticker'].apply(lambda x: str(x).strip().upper())
113
 
 
116
  except Exception as e:
117
  print(f" Spot File Error: {e}")
118
  return pd.DataFrame()
119
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  @staticmethod
121
  def _generate_table_html(title: str, df: pd.DataFrame, headers: List[str], df_cols: List[str]) -> str:
122
  if df.empty:
 
127
  df_display[m] = ""
128
  df_display = df_display[df_cols]
129
  df_display.columns = headers
130
+ # escape=False is critical for rendering ticker links
131
  table_html = df_display.to_html(index=False, classes='table', escape=False)
132
  return f'<div class="table-container"><h2>{title}</h2>{table_html}</div>'
133
 
 
149
  print(f" Futures high-quality filtering error: {e}")
150
  valid_futures['vtmr_display'] = valid_futures['vtmr']
151
 
152
+ # Suffix-based merge to prevent blank column mapping issues
153
  merged = pd.merge(spot_df, valid_futures, on='ticker', how='inner', suffixes=('_spot', '_fut'))
154
  if 'vtmr_fut' in merged.columns:
155
  merged = merged.sort_values('vtmr_fut', ascending=False)
 
236
  """
237
  return html
238
 
 
239
  def crypto_analysis_v4(user_keys, user_id) -> None:
240
  """Main execution flow for Advanced Analysis."""
241
  print(" ADVANCED QUANT CRYPTO VOLUME ANALYSIS")
242
+ print(" Scanning for Futures PDF and Spot CSV/HTML files")
243
  print(" " + "=" * 50)
244
 
245
+ # Find Files
246
  spot_file, futures_file = FileScanner.find_files(user_id)
247
  if not spot_file or not futures_file:
248
  print(" Required files not found.")
249
+ raise FileNotFoundError(" You Need CoinAlyze Futures PDF and Spot Market Data. Kindly Generate Spot Data And Upload Futures PDF First.")
250
 
251
+ # Parse Files
252
+ futures_df = PDFParser.extract(futures_file)
253
  spot_df = DataProcessor.load_spot(spot_file)
254
 
255
  html_content = DataProcessor.generate_html_report(futures_df, spot_df)
256
  if html_content:
257
+ # Create PDF
258
  pdf_path = convert_html_to_pdf(html_content, user_id)
259
 
260
  if pdf_path:
src/services/futures_engine.py CHANGED
@@ -1,15 +1,13 @@
1
  import re
2
  import pandas as pd
3
- from pathlib import Path
4
  from dataclasses import dataclass
5
- from typing import List, Optional, Union
6
 
7
  try:
8
  import pypdf
9
  except Exception:
10
  pypdf = None
11
 
12
-
13
  @dataclass
14
  class TokenData:
15
  ticker: str
@@ -18,12 +16,11 @@ class TokenData:
18
  volume: str
19
  vtmr: float
20
  funding: str = "-"
21
- oi_pct: str = "-"
22
-
23
 
24
  class PDFParser:
25
  """Handles extraction of tabular data from Coinalyze PDFs using regex."""
26
-
27
  FINANCIAL_PATTERN = re.compile(
28
  r'(\$?[+-]?[\d,\.]+[kKmMbB]?)\s+'
29
  r'(\$?[+-]?[\d,\.]+[kKmMbB]?)\s+'
@@ -37,29 +34,76 @@ class PDFParser:
37
  'mkt cap', 'vol 24h', 'vtmr', 'coins', 'all contracts', 'custom metrics', 'watchlists'
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  @classmethod
41
- def extract(cls, path: Union[str, Path]) -> pd.DataFrame:
42
- """Parses Futures PDF and returns a DataFrame containing raw extracted data."""
43
- pdf_path = Path(path)
44
- print(f" Parsing Futures PDF: {pdf_path.name}")
45
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  if pypdf is None:
47
  print(" pypdf not available - PDF parsing disabled.")
48
  return pd.DataFrame()
49
-
50
  data: List[TokenData] = []
51
  try:
52
- reader = pypdf.PdfReader(pdf_path)
53
  for page in reader.pages:
54
  raw = page.extract_text() or ""
55
  lines = [ln.strip() for ln in raw.split("\n") if ln.strip()]
56
  page_data = cls._parse_page_smart(lines)
57
  data.extend(page_data)
58
-
59
  print(f" Extracted {len(data)} futures tokens")
60
  if not data:
61
  return pd.DataFrame()
62
-
63
  df = pd.DataFrame([vars(t) for t in data])
64
  df['ticker'] = df['ticker'].apply(lambda x: re.sub(r'[^A-Z0-9]', '', str(x).upper()))
65
  df = df[df['ticker'].str.len() > 1]
@@ -69,17 +113,6 @@ class PDFParser:
69
  print(f" PDF Error: {e}")
70
  return pd.DataFrame()
71
 
72
- @classmethod
73
- def parse_and_export_csv(cls, pdf_path: Union[str, Path], output_csv_path: Union[str, Path]) -> pd.DataFrame:
74
- """Extracts Futures PDF data and saves raw results to a temporary CSV file."""
75
- df = cls.extract(pdf_path)
76
- if not df.empty:
77
- out_path = Path(output_csv_path)
78
- out_path.parent.mkdir(parents=True, exist_ok=True)
79
- df.to_csv(out_path, index=False, encoding='utf-8')
80
- print(f" Raw Futures CSV saved to: {out_path}")
81
- return df
82
-
83
  @classmethod
84
  def _parse_page_smart(cls, lines: List[str]) -> List[TokenData]:
85
  financials = []
@@ -100,7 +133,7 @@ class PDFParser:
100
  try:
101
  float(vtmr)
102
  financials.append((mc, vol, vtmr, oi_str, fund_str))
103
- except Exception:
104
  raw_text_lines.append(line)
105
  else:
106
  if not line.isdigit() and len(line) > 1:
@@ -140,22 +173,23 @@ class PDFParser:
140
  name, ticker = token_pairs[k]
141
  mc, vol, vtmr, oi_pct, fund_pct = financials[k]
142
 
 
 
 
143
  tokens.append(TokenData(
144
  ticker=ticker,
145
  name=name,
146
  market_cap=mc,
147
  volume=vol,
148
  vtmr=float(vtmr),
149
- funding=fund_pct if fund_pct and fund_pct not in ['-', 'N/A'] else "-",
150
- oi_pct=oi_pct if oi_pct and oi_pct not in ['-', 'N/A'] else "-"
151
  ))
152
  return tokens
153
 
154
  @staticmethod
155
  def _clean_ticker_strict(text: str) -> Optional[str]:
156
- if len(text) > 15:
157
- return None
158
  cleaned = re.sub(r'[^A-Z0-9]', '', text.upper())
159
- if 2 <= len(cleaned) <= 12:
160
- return cleaned
161
  return None
 
1
  import re
2
  import pandas as pd
 
3
  from dataclasses import dataclass
4
+ from typing import List, Optional, Tuple
5
 
6
  try:
7
  import pypdf
8
  except Exception:
9
  pypdf = None
10
 
 
11
  @dataclass
12
  class TokenData:
13
  ticker: str
 
16
  volume: str
17
  vtmr: float
18
  funding: str = "-"
19
+ oiss: str = "-"
 
20
 
21
  class PDFParser:
22
  """Handles extraction of tabular data from Coinalyze PDFs using regex."""
23
+
24
  FINANCIAL_PATTERN = re.compile(
25
  r'(\$?[+-]?[\d,\.]+[kKmMbB]?)\s+'
26
  r'(\$?[+-]?[\d,\.]+[kKmMbB]?)\s+'
 
34
  'mkt cap', 'vol 24h', 'vtmr', 'coins', 'all contracts', 'custom metrics', 'watchlists'
35
  }
36
 
37
+ # --- Signal Helpers (Moved inside to keep logic self-contained) ---
38
+
39
+ @staticmethod
40
+ def _oi_score_and_signal(oi_change: float) -> Tuple[int, str]:
41
+ if oi_change > 0.20: return 5, "Strong"
42
+ if oi_change > 0.10: return 4, "Bullish"
43
+ if oi_change > 0.00: return 3, "Build-Up"
44
+ if oi_change > -0.10: return 2, "Weakening"
45
+ if oi_change > -0.20: return 1, "Exiting"
46
+ return 0, "Exiting"
47
+
48
+ @staticmethod
49
+ def _funding_score_and_signal(funding_val: float) -> Tuple[str, str]:
50
+ if funding_val >= 0.05: return "Greed", "oi-strong"
51
+ if funding_val > 0.00: return "Bullish", "oi-strong"
52
+ if funding_val <= -0.05: return "Extreme Fear", "oi-weak"
53
+ if funding_val < 0.00: return "Bearish", "oi-weak"
54
+ return "Neutral", ""
55
+
56
  @classmethod
57
+ def make_oiss(cls, oi_percent_str: str) -> str:
58
+ if not oi_percent_str: return "-"
59
+ val = oi_percent_str.replace("%", "").strip()
60
+ try:
61
+ oi_change = float(val) / 100
62
+ score, signal = cls._oi_score_and_signal(oi_change)
63
+
64
+ if oi_change > 0: css_class = "oi-strong"
65
+ elif oi_change < 0: css_class = "oi-weak"
66
+ else: css_class = ""
67
+
68
+ sign = "+" if oi_change > 0 else ""
69
+ if css_class:
70
+ return f'<span class="{css_class}">{sign}{oi_change*100:.0f}%</span> {signal}'
71
+ return f"{sign}{oi_change*100:.0f}% {signal}"
72
+ except Exception:
73
+ return "-"
74
+
75
+ @classmethod
76
+ def make_funding_signal(cls, funding_str: str) -> str:
77
+ if not funding_str or funding_str in ['-', 'N/A']: return "-"
78
+ try:
79
+ val = float(funding_str.replace('%', '').strip())
80
+ signal_word, css_class = cls._funding_score_and_signal(val)
81
+
82
+ if css_class:
83
+ return f'<span class="{css_class}">{val}%</span> <span style="font-size:0.8em; color:#7f8c8d;">{signal_word}</span>'
84
+ return f'{val}% {signal_word}'
85
+ except Exception:
86
+ return funding_str
87
+
88
+ # --- Core Extraction Logic ---
89
+
90
+ @classmethod
91
+ def extract(cls, path) -> pd.DataFrame:
92
+ print(f" Parsing Futures PDF: {path.name}")
93
  if pypdf is None:
94
  print(" pypdf not available - PDF parsing disabled.")
95
  return pd.DataFrame()
 
96
  data: List[TokenData] = []
97
  try:
98
+ reader = pypdf.PdfReader(path)
99
  for page in reader.pages:
100
  raw = page.extract_text() or ""
101
  lines = [ln.strip() for ln in raw.split("\n") if ln.strip()]
102
  page_data = cls._parse_page_smart(lines)
103
  data.extend(page_data)
 
104
  print(f" Extracted {len(data)} futures tokens")
105
  if not data:
106
  return pd.DataFrame()
 
107
  df = pd.DataFrame([vars(t) for t in data])
108
  df['ticker'] = df['ticker'].apply(lambda x: re.sub(r'[^A-Z0-9]', '', str(x).upper()))
109
  df = df[df['ticker'].str.len() > 1]
 
113
  print(f" PDF Error: {e}")
114
  return pd.DataFrame()
115
 
 
 
 
 
 
 
 
 
 
 
 
116
  @classmethod
117
  def _parse_page_smart(cls, lines: List[str]) -> List[TokenData]:
118
  financials = []
 
133
  try:
134
  float(vtmr)
135
  financials.append((mc, vol, vtmr, oi_str, fund_str))
136
+ except:
137
  raw_text_lines.append(line)
138
  else:
139
  if not line.isdigit() and len(line) > 1:
 
173
  name, ticker = token_pairs[k]
174
  mc, vol, vtmr, oi_pct, fund_pct = financials[k]
175
 
176
+ oiss_val = cls.make_oiss(oi_pct) if oi_pct and oi_pct not in ['-', 'N/A'] else "-"
177
+ funding_val = cls.make_funding_signal(fund_pct)
178
+
179
  tokens.append(TokenData(
180
  ticker=ticker,
181
  name=name,
182
  market_cap=mc,
183
  volume=vol,
184
  vtmr=float(vtmr),
185
+ funding=funding_val,
186
+ oiss=oiss_val
187
  ))
188
  return tokens
189
 
190
  @staticmethod
191
  def _clean_ticker_strict(text: str) -> Optional[str]:
192
+ if len(text) > 15: return None
 
193
  cleaned = re.sub(r'[^A-Z0-9]', '', text.upper())
194
+ if 2 <= len(cleaned) <= 12: return cleaned
 
195
  return None