portiq-backend / modules /parser.py
ramkumar-bindrix
first commit
5d47dd0
Raw
History Blame Contribute Delete
4.82 kB
"""Module for parsing Zerodha CSV exports."""
import pandas as pd
import io
def clean_numeric_series(series):
"""Removes commas and % signs from a pandas Series, then converts to float."""
return series.astype(str).str.replace(',', '', regex=False).str.replace('%', '', regex=False).astype(float)
def load_portfolio(file_path_or_string):
"""
Reads a Zerodha portfolio CSV and returns a cleaned pandas DataFrame.
Accepts either a file path ending in .csv or a raw CSV text string.
"""
try:
if file_path_or_string.strip().endswith('.csv') and '\n' not in file_path_or_string:
df = pd.read_csv(file_path_or_string.strip())
else:
df = pd.read_csv(io.StringIO(file_path_or_string.strip()))
# Strip whitespace from column names
df.columns = df.columns.str.strip()
# Rename columns. Using a flexible mapping in case of slight variations (like "Qty." vs "Qty")
col_mapping = {
"Instrument": "symbol",
"Qty.": "qty",
"Qty": "qty",
"Avg. cost": "avg_cost",
"LTP": "ltp",
"Invested": "invested",
"Cur. val": "cur_val",
"P&L": "pnl",
"Net chg.": "net_chg",
"Day chg.": "day_chg"
}
df = df.rename(columns=col_mapping)
# Drop rows where symbol is empty or NaN
if 'symbol' in df.columns:
df = df.dropna(subset=['symbol'])
df = df[df['symbol'].astype(str).str.strip() != '']
# Convert numeric columns
numeric_cols = ['qty', 'avg_cost', 'ltp', 'invested', 'cur_val', 'pnl', 'net_chg', 'day_chg']
for col in numeric_cols:
if col in df.columns:
df[col] = clean_numeric_series(df[col])
return df
except Exception as e:
raise ValueError(f"Failed to load or parse portfolio: {e}")
def validate_portfolio(df):
"""
Validates the parsed portfolio DataFrame.
Returns (True, None) if valid.
Returns (False, error_message) if invalid.
"""
try:
if df.empty:
return False, "Portfolio DataFrame is empty (no rows)."
required_cols = ['symbol', 'qty', 'avg_cost', 'ltp', 'invested', 'cur_val', 'pnl', 'net_chg', 'day_chg']
missing_cols = [col for col in required_cols if col not in df.columns]
if missing_cols:
found_cols = list(df.columns)
return False, f"Missing required columns: {', '.join(missing_cols)}\nFound columns: {', '.join(found_cols)}"
if df['symbol'].isnull().any() or (df['symbol'].astype(str).str.strip() == '').any():
return False, "Found rows with null or empty symbols."
return True, None
except Exception as e:
return False, f"Validation failed with error: {e}"
def portfolio_to_text(df):
"""
Converts the cleaned DataFrame into a formatted text summary for AI prompts.
"""
lines = []
for _, row in df.iterrows():
# SYMBOL | Qty: X | Avg: ₹Y | LTP: ₹Z | P&L: ₹A (B%) | Day: C%
sym = row['symbol']
qty = row['qty']
avg = row['avg_cost']
ltp = row['ltp']
pnl = row['pnl']
net = row['net_chg']
day = row['day_chg']
line = f"{sym} | Qty: {qty:g} | Avg: ₹{avg:.2f} | LTP: ₹{ltp:.2f} | P&L: ₹{pnl:.2f} ({net:+.2f}%) | Day: {day:+.2f}%"
lines.append(line)
lines.append("-" * 40)
total_invested = df['invested'].sum() if 'invested' in df.columns else 0.0
total_cur_val = df['cur_val'].sum() if 'cur_val' in df.columns else 0.0
lines.append(f"Total Invested: ₹{total_invested:.2f} | Total Current Value: ₹{total_cur_val:.2f}")
return "\n".join(lines)
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
# Simple test with sample data matching Zerodha format
sample_csv = '''Instrument,Qty.,Avg. cost,LTP,Invested,Cur. val,P&L,Net chg.,Day chg.
RELIANCE,10,"2,450.50","2,500.00","24,505.00","25,000.00","495.00",2.02%,0.50%
TCS,5,"3,200.00","3,150.00","16,000.00","15,750.00","-250.00",-1.56%,-0.20%
,10,100,100,1000,1000,0,0%,0%
'''
print("Loading sample portfolio...")
df = load_portfolio(sample_csv)
print("\nValidating DataFrame...")
is_valid, err = validate_portfolio(df)
print(f"Is valid? {is_valid}")
if not is_valid:
print(f"Error: {err}")
print("\nDataFrame Output:")
print(df.to_string())
print("\nFormatted Text:")
print(portfolio_to_text(df))