This code successfully reports on a wallet's position first time. 

Likewise, if a wallet gets added.

It is not however providing any refresh of positions. Not even just periodically when the mark price has changed and therefore the PnL has adjusted.

import requests
import json
import asyncio
import logging
import html
from collections import defaultdict
from telegram import Update
from telegram.constants import ParseMode
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    JobQueue,
)
import os
import re
from datetime import datetime, timedelta, timezone

# Configuration (Ensure environment variables are set)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
TELEGRAM_CHAT_ID = os.getenv('TELEGRAM_CHAT_ID')
HYPERLIQUID_WS_URL = "wss://api.hyperliquid.xyz/ws"

# Configure logging
log_file = "monitor-purr.log"
logging.basicConfig(
    level=logging.DEBUG,  # Set to DEBUG for detailed logs
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[logging.FileHandler(log_file), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)

# Ensure the logs directory exists
logs_dir = "logs"
if not os.path.exists(logs_dir):
    os.makedirs(logs_dir)

# Global variables
tracked_wallets = {}  # {address: nickname}
previous_pnls = defaultdict(dict)  # {wallet_address: {coin: previous_pnl}}
previous_positions = defaultdict(set)  # {wallet_address: set of coins}
wallet_locks = defaultdict(asyncio.Lock)  # {wallet_address: asyncio.Lock}

def escape_markdown(text):
    """Helper function to escape special characters for MarkdownV2."""
    escape_chars = r'_*[]()~`>#+-=|{}.!'
    return re.sub(f'([{re.escape(escape_chars)}])', r'\\\1', str(text))

def format_number(number, decimals=2, suffix=''):
    """
    Formats a number into a more readable format with suffixes.

    Args:
        number (float or str): The number to format.
        decimals (int): Number of decimal places.
        suffix (str): Optional suffix to append.

    Returns:
        str: Formatted number as a string.
    """
    # Remove currency symbols and commas if present
    if isinstance(number, str):
        number = number.replace('$', '').replace(',', '')

    try:
        number = float(number)
    except ValueError:
        return "N/A"  # Return "N/A" if conversion fails

    if abs(number) >= 1_000_000:
        return f"${number / 1_000_000:.{decimals}f}m{suffix}"
    elif abs(number) >= 1_000:
        return f"${number / 1_000:.{decimals}f}k{suffix}"
    else:
        return f"${number:.{decimals}f}{suffix}"

def parse_formatted_number(s):
    """
    Parses a formatted string with 'k' or 'm' suffixes back to a float.
    
    Args:
        s (str): The formatted string (e.g., '$41k', '$1.2m').

    Returns:
        float: The numerical value.
    """
    s = s.replace('$', '').replace(',', '').strip().lower()
    multiplier = 1
    if s.endswith('k'):
        multiplier = 1_000
        s = s[:-1]
    elif s.endswith('m'):
        multiplier = 1_000_000
        s = s[:-1]
    try:
        return float(s) * multiplier
    except ValueError:
        logger.error(f"Failed to parse number: {s}")
        return 0.0  # or handle as appropriate

def format_crypto_value(value, coin):
    """Formats the crypto value based on its magnitude."""
    return format_number(value)

def safe_request(url, json_data, timeout=10):
    """Makes a safe request to the specified URL, handling potential errors."""
    try:
        response = requests.post(url, json=json_data, timeout=timeout)
        response.raise_for_status()
        return response.json()
    except requests.exceptions.RequestException as e:
        logger.error(f"Request failed: {e}")
        return None

def fetch_user_state(wallet_address):
    """Fetches the user state from the Hyperliquid API."""
    return safe_request("https://api.hyperliquid.xyz/info", {"type": "clearinghouseState", "user": wallet_address})

def fetch_all_mids():
    """Fetches the allMids data from the Hyperliquid API."""
    return safe_request("https://api.hyperliquid.xyz/info", {"type": "allMids"})

def get_open_orders_count(wallet_address, return_data=False):
    """Fetches the number of open orders for a given wallet address."""
    data = safe_request("https://api.hyperliquid.xyz/info", {"type": "frontendOpenOrders", "user": wallet_address})
    return data if return_data else len(data) if data else 0

def get_all_prices(all_mids):
    """Extracts and returns a dictionary of coin prices from the allMids data."""
    if all_mids is None:
        return {}
    return {coin: float(price) for coin, price in all_mids.items()}

def format_user_state(wallet_address, all_mids):
    """Formats the user state into a structured format."""
    user_state = fetch_user_state(wallet_address)
    if not user_state or not user_state.get("assetPositions"):
        return [], {}, 0, 0

    formatted_output = []
    raw_data = {}
    total_upnl = 0
    all_prices = get_all_prices(all_mids)

    try:
        account_equity = float(user_state.get("marginSummary", {}).get("accountValue", 0))
    except (TypeError, ValueError):
        account_equity = 0

    for item in user_state.get("assetPositions", []):
        position = item.get("position", {})
        coin = position.get("coin", "N/A")

        try:
            entryPx = float(position.get("entryPx", 0))
            markPrice = float(all_prices.get(coin, 0))
            size = float(position.get("szi", 0))
            unrealizedPnl = float(position.get("unrealizedPnl", 0))
            leverage = float(position.get("leverage", {}).get("value", 1))
        except (TypeError, ValueError):
            continue

        position_value = abs(size * markPrice)

        position_type = "LONG" if size > 0 else "SHORT" if size < 0 else "N/A"

        if position_type != "N/A":
            formatted_output.append(
                [
                    coin,
                    position_type,
                    format_number(entryPx, decimals=4),
                    format_number(markPrice, decimals=2),
                    format_number(unrealizedPnl, decimals=0),
                    format_number(position_value, decimals=0),
                    format_number(leverage, decimals=0),
                ]
            )
            raw_data[coin] = {
                'coin': coin,
                'type': position_type.upper(),
                'entry_price': entryPx,
                'mark_price': markPrice,
                'unrealized_pnl': unrealizedPnl,
                'size': size,
                'leverage': leverage,
            }
            total_upnl += unrealizedPnl

    return formatted_output, raw_data, total_upnl, account_equity

def check_position_changes(wallet_address, current_positions):
    """
    Checks for any changes in positions (opened or closed) compared to previous positions.
    
    Args:
        wallet_address (str): The wallet address being tracked.
        current_positions (dict): Current positions {coin: position_data}.
    
    Returns:
        tuple: (position_opened (bool), position_closed (bool))
    """
    position_opened = False
    position_closed = False

    # Current set of coins
    current_coins = set(current_positions.keys())

    # Previous set of coins
    previous_coins = previous_positions[wallet_address]

    if not previous_coins:
        # No previous positions, treat all current positions as opened
        position_opened = True
        logger.info(f"Initial positions detected for coins: {', '.join(current_coins)} in wallet {wallet_address}")
    else:
        # Detect opened positions
        opened = current_coins - previous_coins
        if opened:
            position_opened = True
            logger.info(f"Positions opened for coins: {', '.join(opened)} in wallet {wallet_address}")

        # Detect closed positions
        closed = previous_coins - current_coins
        if closed:
            position_closed = True
            logger.info(f"Positions closed for coins: {', '.join(closed)} in wallet {wallet_address}")

    logger.debug(f"check_position_changes - Wallet: {wallet_address}, Opened: {position_opened}, Closed: {position_closed}")

    return position_opened, position_closed

async def update_positions(wallet_address, current_positions, formatted_positions, nickname, previous_positions, previous_pnls, context):
    """Update positions and send Telegram alerts for any changes detected."""
    logger.info(f"Updating positions for {nickname} ({wallet_address})")
    message = f"<b>Wallet Positions Update for {html.escape(nickname)}:</b>\n"
    for position in formatted_positions:
        coin, position_type, entry_price, mark_price, pnl, size, leverage = position
        logger.info(f"Processing position: {coin} {position_type}")

        # Retrieve the raw position data
        raw_position = current_positions.get(coin, {})
        if not raw_position:
            logger.warning(f"No raw data found for coin: {coin}")
            continue

        # Determine if this is the first time checking this position
        is_first_check = coin not in previous_pnls[wallet_address]

        # Calculate PnL difference only if not the first check
        if not is_first_check:
            previous_pnl = previous_pnls[wallet_address].get(coin, 0)
            pnl_diff = raw_position['unrealized_pnl'] - previous_pnl
            # Format the PnL difference with sign and shorthand notation
            if pnl_diff >= 0:
                pnl_diff_str = f" (+{format_number(pnl_diff, decimals=0)[1:]})"
            else:
                pnl_diff_str = f" (-{format_number(abs(pnl_diff), decimals=0)[1:]})"
        else:
            pnl_diff_str = ""  # No diff on first check

        # Update the previous PnL for the next comparison
        previous_pnls[wallet_address][coin] = raw_position['unrealized_pnl']

        # Adjust mark price decimal places based on its value
        mark_price_value = raw_position['mark_price']
        if mark_price_value < 1:
            mark_price_formatted = f"{mark_price_value:.4f}"
        else:
            mark_price_formatted = f"{mark_price_value:.2f}"

        # Format entry price with 4 decimal places if needed
        entry_price_value = raw_position['entry_price']
        entry_price_formatted = f"{entry_price_value:.4f}" if entry_price_value < 1 else f"{entry_price_value:.2f}"

        # Format PnL
        formatted_pnl = format_number(raw_position['unrealized_pnl'], decimals=0)

        # Format size: show as positive for SHORT positions
        display_size = abs(raw_position['size'])
        formatted_size = format_number(display_size, decimals=0)

        # Format leverage
        formatted_leverage = format_number(raw_position['leverage'], decimals=0)

        # Assemble the message line
        message += (
            f"- {html.escape(coin)} {formatted_size} {html.escape(position_type)}, "
            f"Entry: {entry_price_formatted}, "
            f"Mark: {mark_price_formatted}, "
            f"PnL: {formatted_pnl}{pnl_diff_str}\n"
        )

    logger.info(f"Sending message to Telegram: {message}")
    try:
        await context.bot.send_message(chat_id=TELEGRAM_CHAT_ID, text=message, parse_mode=ParseMode.HTML)
        logger.info("Telegram position update sent successfully.")
    except Exception as e:
        logger.error(f"Failed to send message: {e}")

    # Check for closed positions and send alerts
    for coin in list(previous_positions[wallet_address]):
        if coin not in current_positions:
            close_message = (
                f"<b>ALERT: Position Closed</b>\n"
                f"{html.escape(nickname)} | {html.escape(coin)}"
            )
            try:
                await context.bot.send_message(chat_id=TELEGRAM_CHAT_ID, text=close_message, parse_mode=ParseMode.HTML)
                logger.info(f"Sent position closed alert for {coin}.")
            except Exception as e:
                logger.error(f"Failed to send position closed alert for {coin}: {e}")

    # Update previous_positions to reflect current positions
    previous_positions[wallet_address] = set(current_positions.keys())
    return current_positions, previous_positions
async def send_telegram_message(message, parse_mode=None, context=None):
    """Format and send a message to the Telegram chat ID."""
    if context is None:
        logger.error("Context is None. Cannot send message.")
        return
    try:
        await context.bot.send_message(chat_id=TELEGRAM_CHAT_ID, text=message, parse_mode=parse_mode)
        logger.info("Telegram message sent successfully.")
    except Exception as e:
        logger.error(f"Failed to send message: {e}")

async def job_callback(context: ContextTypes.DEFAULT_TYPE):
    """Asynchronous callback function for scheduled jobs."""
    wallet_address = context.job.data['wallet_address']
    nickname = tracked_wallets.get(wallet_address, wallet_address)
    logger.info(f"Running job for wallet: {nickname} ({wallet_address})")

    # Acquire the lock for this wallet to prevent overlapping jobs
    async with wallet_locks[wallet_address]:
        logger.debug(f"Acquired lock for wallet: {wallet_address}")
        await process_wallet(wallet_address, nickname, context)
        logger.debug(f"Released lock for wallet: {wallet_address}")

async def process_wallet(wallet_address, wallet_name, context):
    logging.info(f"Processing wallet: {wallet_name} ({wallet_address})")
    
    try:
        user_state = fetch_user_state(wallet_address)
        if not user_state:
            logger.warning(f"User state is empty for wallet: {wallet_address}")
            return
        logging.info(f"User state fetched for {wallet_address}")
        
        all_mids = fetch_all_mids()
        formatted_positions, raw_data, total_upnl, account_equity = format_user_state(wallet_address, all_mids)
        logging.info(f"Formatted positions: {formatted_positions}")
        
        open_orders = get_open_orders_count(wallet_address, return_data=True)
        logging.info(f"Open orders: {open_orders}")
        
        current_positions = raw_data
        position_opened, position_closed = check_position_changes(
            wallet_address, current_positions
        )
        logger.debug(f"Position opened: {position_opened}, Position closed: {position_closed}")
        
        if position_opened or position_closed:
            await update_positions(
                wallet_address,
                current_positions,
                formatted_positions,
                wallet_name,
                previous_positions,
                previous_pnls,
                context
            )
        else:
            logger.info(f"No position changes detected for wallet {wallet_address}. No update sent.")

    except Exception as e:
        logging.error(f"Error processing wallet {wallet_address}: {e}", exc_info=True)

async def background_task(context: ContextTypes.DEFAULT_TYPE):
    """Periodic background task to check wallet positions."""
    for wallet_address, nickname in tracked_wallets.items():
        context.job_queue.run_repeating(
            job_callback,
            interval=20,  # Every 20 seconds
            first=0,
            data={'wallet_address': wallet_address},
            name=f'Periodic Wallet Check - {wallet_address}'
        )

# Telegram bot command handlers
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Send a message when the command /start is issued."""
    start_message = (
        "Bot started. Use these commands:\n"
        "/add <wallet_address> [nickname] - Add a wallet to track\n"
        "/remove <wallet_address or nickname> - Remove a wallet\n"
        "/list - List tracked wallets\n"
        "/details <wallet_address or nickname> - Show wallet details"
    )
    await update.message.reply_text(start_message)
    logger.info(f"User {update.effective_user.id} started the bot.")

async def add_wallet(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Adds a wallet address to the tracking list with an optional nickname."""
    if len(context.args) == 0:
        await update.message.reply_text("Please provide a wallet address to add.")
        return

    wallet_address = context.args[0]
    nickname = " ".join(context.args[1:]) if len(context.args) > 1 else wallet_address
    tracked_wallets[wallet_address] = nickname

    # Schedule a job for the new wallet
    context.job_queue.run_repeating(
        job_callback,
        interval=10,  # Every 10 seconds
        first=0,
        data={'wallet_address': wallet_address},
        name=f'Periodic Wallet Check - {wallet_address}'
    )

    await update.message.reply_text(f"Added wallet {nickname} ({wallet_address}) to tracking.")
    logger.info(f"Added wallet {nickname} ({wallet_address}) to tracking.")

async def remove_wallet(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Removes a wallet address from the tracking list."""
    if not context.args:
        await update.message.reply_text("Please provide a wallet address or nickname to remove.")
        return

    identifier = context.args[0]
    wallet_address = next((addr for addr, name in tracked_wallets.items() if identifier in [addr, name]), None)

    if wallet_address:
        nickname = tracked_wallets.pop(wallet_address)
        # Remove all jobs related to this wallet
        jobs = context.job_queue.get_jobs_by_name(f'Periodic Wallet Check - {wallet_address}')
        for job in jobs:
            job.schedule_removal()
        # Remove tracking data
        previous_pnls.pop(wallet_address, None)
        previous_positions.pop(wallet_address, None)
        await update.message.reply_text(f"Removed wallet {nickname} ({wallet_address}) from tracking.")
        logger.info(f"Removed wallet {nickname} ({wallet_address}) from tracking.")
    else:
        await update.message.reply_text(f"Wallet {identifier} is not being tracked.")
        logger.warning(f"Attempted to remove non-tracked wallet {identifier}.")

async def list_wallets(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Lists all tracked wallet addresses with their nicknames."""
    if tracked_wallets:
        wallet_list = "\n".join([f"{escape_markdown(name)} ({address})" for address, name in tracked_wallets.items()])
        await update.message.reply_text(f"Currently tracking these wallets:\n{wallet_list}", parse_mode=ParseMode.HTML)
        logger.info(f"Listed wallets for user {update.effective_user.id}.")
    else:
        await update.message.reply_text("Not tracking any wallets yet.")
        logger.info(f"User {update.effective_user.id} requested wallet list but none are being tracked.")

async def wallet_details(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Shows detailed information about a specific wallet."""
    if not context.args:
        await update.message.reply_text("Please provide a wallet address or nickname to view details.")
        return

    identifier = context.args[0]
    wallet_address = next((address for address, nickname in tracked_wallets.items() if identifier in [address, nickname]), None)

    if not wallet_address:
        await update.message.reply_text(f"Wallet {identifier} is not being tracked.")
        logger.warning(f"User {update.effective_user.id} requested details for non-tracked wallet {identifier}.")
        return

    try:
        user_state = fetch_user_state(wallet_address)
        open_orders = get_open_orders_count(wallet_address, return_data=True)
        all_mids = fetch_all_mids()

        formatted_positions, raw_data, total_upnl, account_equity = format_user_state(wallet_address, all_mids)

        wallet_name = tracked_wallets.get(wallet_address, wallet_address)
        message = f"<b>Wallet Details: {escape_markdown(wallet_name)}</b>\n"

        if not formatted_positions and account_equity == 0 and not open_orders:
            message += "Currently inactive"
        else:
            message += f"- Margin: {escape_markdown(format_number(account_equity))}\n\n"

            if formatted_positions:
                message += "<b>Positions:</b>\n"
                for position in formatted_positions:
                    coin, position_type, entry_price, mark_price, pnl, size, leverage = position
                    
                    # Retrieve raw position data
                    raw_position = raw_data.get(coin, {})
                    
                    # Determine if this is the first time checking this position
                    is_first_check = coin not in previous_pnls[wallet_address]

                    # Calculate PnL difference only if not the first check
                    if not is_first_check:
                        previous_pnl = previous_pnls[wallet_address].get(coin, 0)
                        pnl_diff = raw_position['unrealized_pnl'] - previous_pnl
                        # Format the PnL difference with sign and shorthand notation
                        if pnl_diff >= 0:
                            pnl_diff_str = f" (+{format_number(pnl_diff, decimals=0)[1:]})"
                        else:
                            pnl_diff_str = f" (-{format_number(abs(pnl_diff), decimals=0)[1:]})"
                    else:
                        pnl_diff_str = ""  # No diff on first check

                    # Update the previous PnL for the next comparison
                    previous_pnls[wallet_address][coin] = raw_position['unrealized_pnl']

                    # Adjust mark price decimal places based on its value
                    mark_price_value = raw_position['mark_price']
                    if mark_price_value < 1:
                        mark_price_formatted = f"{mark_price_value:.4f}"
                    else:
                        mark_price_formatted = f"{mark_price_value:.2f}"

                    # Format entry price with 4 decimal places if needed
                    entry_price_value = raw_position['entry_price']
                    entry_price_formatted = f"{entry_price_value:.4f}" if entry_price_value < 1 else f"{entry_price_value:.2f}"

                    # Format PnL
                    formatted_pnl = format_number(raw_position['unrealized_pnl'], decimals=0)

                    # Format size: show as positive for SHORT positions
                    display_size = abs(raw_position['size'])
                    formatted_size = format_number(display_size, decimals=0)

                    # Format leverage
                    formatted_leverage = format_number(raw_position['leverage'], decimals=0)

                    # Assemble the message line
                    message += (
                        f"- <b>{escape_markdown(coin)}</b> {formatted_size} {escape_markdown(position_type)}, "
                        f"Entry: {entry_price_formatted}, "
                        f"Mark: {mark_price_formatted},\n"
                        f"  Size: {formatted_size}x, "
                        f"<b>PnL: {formatted_pnl}</b>{pnl_diff_str}\n"
                    )
            else:
                message += "<b>Positions:</b> N/A\n"

            open_orders_message = (
                "\n".join(
                    [
                        f"- <b>{escape_markdown(order.get('coin', 'N/A'))}</b>: "
                        f"Type: {escape_markdown(order.get('type', 'N/A'))}, "
                        f"Side: {escape_markdown(order.get('side', 'N/A'))}, "
                        f"Price: {escape_markdown(str(order.get('px', 'N/A')))}, "
                        f"Quantity: {escape_markdown(str(order.get('qty', 'N/A')))}"
                        for order in open_orders
                    ]
                )
                if open_orders
                else "N/A"
            )
            message += f"\n<b>Open Orders:</b> {open_orders_message}\n"

        chat_id = update.effective_chat.id if update and update.effective_chat else TELEGRAM_CHAT_ID
        await context.bot.send_message(chat_id=chat_id, text=message, parse_mode=ParseMode.HTML)
        logger.info(f"Sent wallet details for {wallet_name} to Telegram.")

    except Exception as e:
        logger.error(f"Error fetching wallet details for {wallet_address}: {e}", exc_info=True)
        error_message = f"Failed to fetch wallet details. Error: {str(e)}"
        await context.bot.send_message(chat_id=TELEGRAM_CHAT_ID, text=error_message)
        logger.info(f"Sent error message to Telegram for wallet {wallet_address}.")

async def periodic_check(context: ContextTypes.DEFAULT_TYPE):
    """Periodic check for all tracked wallets."""
    for wallet_address, nickname in tracked_wallets.items():
        try:
            user_state = fetch_user_state(wallet_address)
            if not user_state:
                logger.warning(f"No user state found for wallet: {wallet_address}")
                continue

            all_mids = fetch_all_mids()
            formatted_positions, raw_data, total_upnl, account_equity = format_user_state(wallet_address, all_mids)
            open_orders = get_open_orders_count(wallet_address, return_data=True)

            current_positions = raw_data
            position_opened, position_closed = check_position_changes(
                wallet_address, current_positions
            )

            if position_opened or position_closed:
                await update_positions(
                    wallet_address,
                    current_positions,
                    formatted_positions,
                    nickname,
                    previous_positions,
                    previous_pnls,
                    context
                )

        except Exception as e:
            logger.error(f"Error processing wallet {wallet_address}: {e}", exc_info=True)

async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE):
    """Handle errors caused by updates."""
    logger.error(msg="Exception while handling an update:", exc_info=context.error)
    # Optionally, notify the admin via Telegram or another method
    if TELEGRAM_CHAT_ID:
        try:
            await context.bot.send_message(
                chat_id=TELEGRAM_CHAT_ID,
                text=f"An error occurred: {context.error}"
            )
        except Exception as e:
            logger.error(f"Failed to send error message to Telegram: {e}")

def main():
    """Main function to start the bot."""
    logger.info("Starting the bot...")

    # Initialize the Application
    application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()

    # Add command handlers
    application.add_handler(CommandHandler("start", start_command))
    application.add_handler(CommandHandler("add", add_wallet))
    application.add_handler(CommandHandler("remove", remove_wallet))
    application.add_handler(CommandHandler("list", list_wallets))
    application.add_handler(CommandHandler("details", wallet_details))

    # Add error handler
    application.add_error_handler(error_handler)

    # Add default wallet
    default_wallet = "0xcd278177b13eb5c269fed43960c4171b66df5b9c"
    default_nickname = "kPepe Shorter"
    tracked_wallets[default_wallet] = default_nickname

    logger.info(f"Added default wallet: {default_nickname} ({default_wallet})")

    # Schedule a job for the default wallet
    job_queue: JobQueue = application.job_queue
    job_queue.run_repeating(
        job_callback,
        interval=10,  # Every 10 seconds
        first=0,
        data={'wallet_address': default_wallet},
        name=f'Periodic Wallet Check - {default_wallet}'
    )

    # Run the bot
    application.run_polling()
    logger.info("Bot started successfully.")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        logger.info("Bot stopped by user.")
    except Exception as e:
        logger.error(f"An unexpected error occurred: {e}", exc_info=True)
    finally:
        logger.info("Script finished.")