diff --git a/README.md b/README.md index f93bbb0..c4c0cc2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ pip install -r requirements.txt ## List all portfolios: ```python -(env): $ python script.py list-portfolios +(env): $ python cli.py list-portfolios Output: Your Portfolios: - Strategies (ID: 961635) diff --git a/cli.py b/cli.py index 8f934d0..a53137e 100644 --- a/cli.py +++ b/cli.py @@ -1,6 +1,9 @@ import os -import requests import click +import datetime +from dateutil.relativedelta import relativedelta +import requests +import yfinance as yf from dotenv import load_dotenv load_dotenv() @@ -54,7 +57,24 @@ def get_holdings(portfolio_id): return response.json()["holdings"] else: raise Exception(f"Error fetching holdings: {response.status_code} {response.text}") - + +def get_stock_price(symbol, date): + """Get the stock price for a given symbol and date.""" + formatted_date = date.strftime("%Y-%m-%d") + print(formatted_date) + try: + stock = yf.Ticker(f"{symbol}.AX") + df = stock.history(start=formatted_date, end=formatted_date) + if not df.empty: + print(df) + return df["Close"].values[0] + else: + print(f"No price data found for {symbol} on {formatted_date}.") + return None + except Exception as e: + print(f"Exception while fetching price for {symbol} on {formatted_date}: {e}") + return None + def delete(portfolio_id): """Delete a portfolio with the given ID - uses V2 legacy API endpoint""" global ACCESS_TOKEN @@ -67,9 +87,29 @@ def delete(portfolio_id): else: raise Exception(f"Error deleting portfolio: {response.status_code} {response.text}") +# Define a new function to delete a portfolio with the given portfolio ID +def delete_portfolio_func(portfolio_id): + try: + delete(portfolio_id) + except Exception as e: + click.echo(f"Error deleting portfolio: {e}", err=True) + +# Define a new function to create a portfolio +def create_portfolio_func(name): + global ACCESS_TOKEN + url = f"{API_BASE_URL}/{LEGACY_VERSION}/portfolios" + headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} + + try: + response = requests.post(url, headers=headers, json={"name": name}) + response.raise_for_status() + click.echo("Portfolio created successfully.") + except requests.exceptions.RequestException as e: + raise Exception(f"Error creating portfolio: {e}") + @click.group() def cli(): - """ShareSight Portfolio Manager CLI""" + """Sharesight CLI""" try: global ACCESS_TOKEN ACCESS_TOKEN = request_access_token() @@ -77,6 +117,21 @@ def cli(): click.echo(f"Error: {e}", err=True) raise click.Abort() + +# Define the CLI command for deleting a portfolio +@cli.command() +@click.argument("portfolio_id", type=int) +def delete_portfolio(portfolio_id): + """Delete a portfolio with the given portfolio ID.""" + delete_portfolio_func(portfolio_id) + +# Define the CLI command for creating a portfolio +@cli.command() +@click.argument("name") +def create_portfolio(name): + """Create a new portfolio.""" + create_portfolio_func(name) + @cli.command() def list_portfolios(): """List all portfolios.""" @@ -100,41 +155,148 @@ def list_holdings(portfolio_id): except Exception as e: click.echo(f"Error fetching holdings: {e}", err=True) -@cli.command() -@click.argument("portfolio_id", type=int) -def delete_portfolio(portfolio_id): - """Delete a portfolio with a given ID.""" +def get_periodic_dates(start_date, end_date, periodic_buy): + dates = [] + current_date = start_date + while current_date <= end_date: + dates.append(current_date) # Append datetime.date object instead of string + if periodic_buy == "weekly": + current_date += datetime.timedelta(weeks=1) + elif periodic_buy == "monthly": + current_date += relativedelta(months=1) + elif periodic_buy == "quarterly": + current_date += relativedelta(months=3) + elif periodic_buy == "semi-annual": + current_date += relativedelta(months=6) + elif periodic_buy == "annual": + current_date += relativedelta(years=1) + return dates + +def push_trade_to_sharesight(trade): + """Push a trade to Sharesight.""" + global ACCESS_TOKEN + url = f"{API_BASE_URL}{LEGACY_VERSION}/trades.json" + headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} + try: - delete(portfolio_id) - except Exception as e: - click.echo(f"Error deleting portfolio: {e}", err=True) + response = requests.post(url, headers=headers, json={"trade": trade}) + response.raise_for_status() + print(response) + except requests.exceptions.RequestException as e: + raise Exception(f"Error pushing trade to Sharesight: {e}") + +def push_trades(total_capital, start_date, end_date, sorted_rows, weights, portfolio_id, periodic_buy): + + # Convert start_date and end_date to strings + start_date_str = start_date.strftime("%Y-%m-%d") + end_date_str = end_date.strftime("%Y-%m-%d") + num_days = (end_date - start_date).days + if periodic_buy == "weekly": + num_buy_orders = max(num_days // 7, 1) + elif periodic_buy == "monthly": + num_buy_orders = max(num_days // 30, 1) + elif periodic_buy == "quarterly": + num_buy_orders = max(num_days // 90, 1) + elif periodic_buy == "semi-annual": + num_buy_orders = max(num_days // 180, 1) + elif periodic_buy == "annual": + num_buy_orders = max(num_days // 365, 1) + + print(num_buy_orders) + + # Push trades to Sharesight for each buy order + for row, weight in zip(sorted_rows, weights): + # Calculate the capital for this row based on its weight + row_capital = total_capital * weight + + # Calculate the amount of capital for each buy order + capital_per_buy_order = row_capital / num_buy_orders + + for buy_date in get_periodic_dates(start_date, end_date, periodic_buy): + try: + stock_price = get_stock_price(row["ticker"], buy_date) + except Exception as e: + print(f"Error: {e}") + continue # Skip this trade and proceed with the next one + + if stock_price is None: + print(f"Skipping trade for {row['ticker']} on {buy_date}: No price data found.") + continue # Skip this trade and proceed with the next one + + # Continue with the rest of the trade process using the stock_price + # Calculate the number of units to buy, factoring in brokerage fees + units_to_buy = (capital_per_buy_order - 9.95) / stock_price + + # Create a trade for this row with the calculated units to buy + trade = { + "unique_identifier": f"{row['ticker']}-{buy_date}", + "transaction_type": "BUY", + "transaction_date": buy_date.strftime("%Y-%m-%d"), # No need to format here + "portfolio_id": portfolio_id, # Use the user-input portfolio ID here + "symbol": row["ticker"], + "market": "ASX", # Replace with the actual market code + "quantity": units_to_buy, + "price": stock_price, + "exchange_rate": 1.0, # Replace with the actual exchange rate if applicable + } + + # Call the function to push the trade to Sharesight + push_trade_to_sharesight(trade) + + click.echo("Trades were pushed to Sharesight successfully.") + +def get_user_input(): + portfolio_id = click.prompt("Enter portfolio ID:", type=int) + total_capital = click.prompt("Enter total capital:", type=float, default=100000) + start_date = click.prompt("Enter start date (YYYY-MM-DD):", type=click.DateTime(formats=["%Y-%m-%d"])) + end_date = click.prompt("Enter end date (YYYY-MM-DD):", type=click.DateTime(formats=["%Y-%m-%d"])) + periodic_buy = click.prompt("Enter periodic buy (weekly/monthly/quarterly/semi-annual/annual):", + type=click.Choice(["weekly", "monthly", "quarterly", "semi-annual", "annual"])) + return total_capital, start_date, end_date, periodic_buy, portfolio_id @cli.command() @click.argument("file", type=click.Path(exists=True)) -def read_csv(file): - """Generate a list of securities from CSV""" +@click.pass_context +def build_portfolio_from_csv(ctx, file): + """Generate a list of securities from CSV and designs a value weighted portfolio (experimental)""" rows = tools.read.read_data_from_csv(file) + value_scores = [] # Store the value scores for each row + for row in rows: - # Get the ticker from each row - ticker = row["TICKER"] - # Get additional fields using the get_fields function + ticker = row["ticker"] + row["value"] = float(row["value"]) additional_fields = tools.api.get_fields(ticker) - # Add the additional fields to the row row.update(additional_fields) + row["value_score"] = tools.value.calculate_value_score(row) + value_scores.append(row["value_score"]) - # Calculate the percentage difference between the current price and the 'value' field - if "current_price" in row and "VALUE" in row: - current_price = row["current_price"] - value = float(row["VALUE"]) - if value != 0: - percentage_diff = abs((current_price - value) / value) * 100 - else: - percentage_diff = 0.0 - row["percentage_difference"] = percentage_diff - - # Print the updated row - print(row) + # Call the function to assign weights based on value scores + weights = tools.weights.assign_weights(value_scores) + + # Sort the rows by value score (in descending order) + sorted_rows = sorted(rows, key=lambda x: x["value_score"], reverse=True) + + # Print the sorted rows with formatted stability and weight + for row, weight in zip(sorted_rows, weights): + # Convert value to float + # Format stability as float with 2 decimal places + stability = row.get("stability") + if stability is not None: + stability = round(float(stability) * 100, 2) + row["stability"] = stability + + # Format weight as float with 2 decimal places + row["weight"] = round(weight * 100, 2) + + # readable output + print(row) + # Ask the user if they want to push trades to Sharesight + if click.confirm("Do you want to push the trades to Sharesight?", default=False): + total_capital, start_date, end_date, periodic_buy, portfolio_id = get_user_input() + push_trades(total_capital=total_capital, start_date=start_date, end_date=end_date, + sorted_rows=sorted_rows, weights=weights, portfolio_id=portfolio_id, periodic_buy=periodic_buy) + if __name__ == "__main__": cli() \ No newline at end of file diff --git a/data/base-data.csv b/data/base-data.csv new file mode 100644 index 0000000..5d77494 --- /dev/null +++ b/data/base-data.csv @@ -0,0 +1,14 @@ +ticker,PLVR,value +NHC,50,6.04 +BHP,80,42.57 +FMG,65,18.6 +WHC,55,10.7 +WBC,80,28.47 +RIO,80,123.94 +WES,80,48.95 +WDS,75,29.08 +STO,70,10.09 +TLS,80,6.17 +WOR,55,18.3 +LYC,55,9.08 +CSL,80,192.86 \ No newline at end of file diff --git a/data/data.csv b/data/data.csv deleted file mode 100644 index 16086a6..0000000 --- a/data/data.csv +++ /dev/null @@ -1,17 +0,0 @@ -TICKER,PLVR,VALUE -NHC,50,9.64 -YAL,40,9.90 -WHC,55,11.42 -RPL,40,3.39 -LYC,55,11.69 -RIO,80,116.91 -BHP,80,41.00 -FMG,65,17.64 -WDS,75,29.03 -CBA,80,90.98 -TLS,80,6.17 -WES,80,48.35 -STO,70,10.13 -WOR,55,18.73 -WBC,80, -CSL,80, \ No newline at end of file diff --git a/data/data.numbers b/data/data.numbers new file mode 100755 index 0000000..ef1f2d6 Binary files /dev/null and b/data/data.numbers differ diff --git a/data/output-2308.json b/data/output-2308.json new file mode 100644 index 0000000..633f14e --- /dev/null +++ b/data/output-2308.json @@ -0,0 +1,143 @@ +{ + "ticker": "NHC", + "PLVR": "50", + "value": 8.85, + "yield": 10.5299994, + "stability": 33.99, + "price": 5.78000020980835, + "sector": "Energy", + "value_score": 4.312249491621285, + "weight": 13.01 +} +{ + "ticker": "WHC", + "PLVR": "55", + "value": 5.93, + "yield": 9.9300005, + "stability": 13.44, + "price": 7.34499979019165, + "sector": "Energy", + "value_score": 4.074267999525998, + "weight": 10.79 +} +{ + "ticker": "WDS", + "PLVR": "75", + "value": 18.47, + "yield": 9.86, + "stability": 49.06, + "price": 37.84000015258789, + "sector": "Energy", + "value_score": 3.8088252785855885, + "weight": 11.43 +} +{ + "ticker": "FMG", + "PLVR": "65", + "value": 18.78, + "yield": 9.56, + "stability": 77.12, + "price": 21.084999084472656, + "sector": "Basic Materials", + "value_score": 3.7887258592594657, + "weight": 12.29 +} +{ + "ticker": "BHP", + "PLVR": "80", + "value": 42.96, + "yield": 8.959999999999999, + "stability": 90.26, + "price": 44.11000061035156, + "sector": "Basic Materials", + "value_score": 3.5742700813576427, + "weight": 8.37 +} +{ + "ticker": "WBC", + "PLVR": "80", + "value": 17.31, + "yield": 6.7, + "stability": 71.67, + "price": 21.18000030517578, + "sector": "Financial Services", + "value_score": 2.773247797422381, + "weight": 6.25 +} +{ + "ticker": "STO", + "PLVR": "70", + "value": 10.13, + "yield": 4.2600001999999995, + "stability": 25.44, + "price": 7.695000171661377, + "sector": "Energy", + "value_score": 2.0832830507310076, + "weight": 5.25 +} +{ + "ticker": "RIO", + "PLVR": "80", + "value": 116.91, + "yield": 4.9799999999999995, + "stability": 91.53, + "price": 107.75499725341797, + "sector": "Basic Materials", + "value_score": 2.0721991590588074, + "weight": 11.49 +} +{ + "ticker": "TLS", + "PLVR": "80", + "value": 5.1, + "yield": 4.1500002, + "stability": 95.81, + "price": 4.065000057220459, + "sector": "Communication Services", + "value_score": 1.8163615899702186, + "weight": 6.29 +} +{ + "ticker": "WES", + "PLVR": "80", + "value": 48.35, + "yield": 3.63, + "stability": 80.83, + "price": 49.4900016784668, + "sector": "Consumer Cyclical", + "value_score": 1.7383353043298162, + "weight": 5.48 +} +{ + "ticker": "WOR", + "PLVR": "55", + "value": 12.3, + "yield": 2.9000001, + "stability": None, + "price": 17.90999984741211, + "sector": "Energy", + "value_score": 1.6034422485127184, + "weight": 4.84 +} +{ + "ticker": "CSL", + "PLVR": "80", + "value": 155.0, + "yield": 1.35, + "stability": 50.65, + "price": 263.1600036621094, + "sector": "Healthcare", + "value_score": 0.8679280175333433, + "weight": 1.89 +} +{ + "ticker": "LYC", + "PLVR": "55", + "value": 5.56, + "yield": None, + "stability": 0.0, + "price": 7.159999847412109, + "sector": "Basic Materials", + "value_score": 0.6261452583471812, + "weight": 2.62 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c1231ca..e7f053a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ altair==5.0.1 appdirs==1.4.4 +art==6.0 attrs==23.1.0 beautifulsoup4==4.12.2 blinker==1.6.2 diff --git a/tools/__init__.py b/tools/__init__.py index 9f44b80..8b388f1 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1 +1 @@ -from .scripts import api, read, weights \ No newline at end of file +from .scripts import api, read, weights, value \ No newline at end of file diff --git a/tools/scripts/api.py b/tools/scripts/api.py index 657ceda..01d6d65 100644 --- a/tools/scripts/api.py +++ b/tools/scripts/api.py @@ -1,18 +1,23 @@ +import requests import yfinance as yf +from bs4 import BeautifulSoup def get_fields(ticker): - """Get additional fields for a given ticker.""" - ticker_data = yf.Ticker(ticker+'.AX') - dividend_yield = ticker_data.info.get("trailingAnnualDividendYield", None) - stability = None # Add code here to fetch historical stability percent from a data source - current_price = ticker_data.info.get("regularMarketPrice", None) - sector_code = ticker_data.info.get("sector", None) - company_name = ticker_data.info.get("longName", None) + + """Get additional fields for a given ticker via yfinance""" + company = yf.Ticker(ticker+'.AX') + dividend_yield = company.info.get("dividendYield", None) + stability = company.info.get("payoutRatio", None) # You can fetch this from a different source if available + current_price = company.history(period="1d")["Close"].iloc[-1] + sector_code = company.info.get("sector", None) + + # Convert dividend_yield to percentage + if dividend_yield is not None: + dividend_yield = dividend_yield * 100 return { - "dividend_yield": dividend_yield, + "yield": dividend_yield, "stability": stability, - "current_price": current_price, - "sector_code": sector_code, - "company_name": company_name, + "price": current_price, + "sector": sector_code, } \ No newline at end of file diff --git a/tools/scripts/read.py b/tools/scripts/read.py index 357b422..bb71258 100644 --- a/tools/scripts/read.py +++ b/tools/scripts/read.py @@ -8,4 +8,5 @@ def read_data_from_csv(file_path): reader = csv.DictReader(csvfile) rows = list(reader) - return rows \ No newline at end of file + return rows + diff --git a/tools/scripts/value.py b/tools/scripts/value.py new file mode 100644 index 0000000..c73416f --- /dev/null +++ b/tools/scripts/value.py @@ -0,0 +1,27 @@ +# tools/value_score.py +def calculate_value_score(row): + # Define weights for each parameter (you can adjust these as needed) + weight_yield = 0.35 + weight_stability = 0.3 + weight_value = 0.35 + + # Handle missing values for yield and stability + yield_percentage = float(row["yield"]) if row["yield"] is not None else 0.0 + stability = float(row["stability"]) if row["stability"] is not None else 0.0 + + # Convert value to float + value = float(row["value"]) + + # Calculate the value score using the weighted sum of each parameter + # Adjust weight_value based on value relative to price + if value > row["price"]: + weight_value *= 0.8 # Reduce weight if value is higher than price + elif value < row["price"]: + weight_value *= 1.2 # Increase weight if value is lower than price + + value_score = ( + weight_yield * yield_percentage + + weight_stability * (1 - stability) + + weight_value * (value / row["price"]) + ) + return value_score diff --git a/tools/scripts/weights.py b/tools/scripts/weights.py index e69de29..f571afd 100644 --- a/tools/scripts/weights.py +++ b/tools/scripts/weights.py @@ -0,0 +1,9 @@ +# tools/weights.py +def assign_weights(value_scores): + # Calculate the sum of all value scores + total_score = sum(value_scores) + + # Calculate weights based on the value score + weights = [score / total_score for score in value_scores] + + return weights