Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,49 @@ uv run stop-loss calculate --percentage 7.5
uv run stop-loss calculate -p 10
```

### Entry Price Support (Trailing Mode Only)

Specify your entry price to set a floor for trailing stop-loss calculations based on your actual cost basis:

```bash
# CLI with entry prices (format: TICKER:PRICE) - requires --trailing
uv run stop-loss calculate AAPL:150 GOOGL:2800 SHOP.TO:200 --trailing -p 5

# Mixed format (some with entry prices, some without)
uv run stop-loss calculate AAPL:150 NVDA --trailing # NVDA uses database high water mark

# Entry prices work only with trailing mode
uv run stop-loss calculate AAPL:175.50 --trailing -p 5
```

**How it works in trailing mode:**
- Entry price sets the **minimum high water mark** (`max(db_hwm, entry_price)`)
- Ensures your stop never goes below your entry point
- As price rises above entry, stop-loss trails normally
- Mixed usage: Tickers without entry prices use database high water mark only

**Why trailing mode only?**
Entry prices represent your cost basis. Trailing mode naturally protects gains from that basis point forward, making the math intuitive. Simple and ATR modes calculate from current price, where entry price creates confusing "risk" calculations.

**Example:**
```bash
$ uv run stop-loss calculate AAPL:180 --trailing -p 5

# You bought at $180, stock went to $200, now at $195
# High water mark = max($200 from DB, $180 entry) = $200
# Stop-loss = $200 * 0.95 = $190
# Risk = $195 - $190 = $5 per share
```

**Entry Price in Config:**
```toml
tickers = [
"AAPL:150.50", # Entry price $150.50
"SHOP.TO:200", # Entry price $200 CAD
"NVDA", # No entry - uses current price
]
```

### Trailing Stop-Loss

Enable trailing stop-loss (tracks high-water mark):
Expand Down
18 changes: 14 additions & 4 deletions config.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# Trailing Stop-Loss Configuration

# List of ticker symbols to track
# Format: "TICKER" or "TICKER:PRICE" (where PRICE is your entry price)
# Note: Entry prices only work with trailing mode (set trailing_enabled = true)
tickers = [
"AAPL",
"AMD",
"SHOP.TO",
"NVDA",
"AAPL", # Uses current price
"AMD", # Uses current price
"SHOP.TO", # Uses current price
"NVDA", # Uses current price
]

# Example with entry prices (requires trailing_enabled = true):
# tickers = [
# "AAPL:150.50", # Entry price $150.50 sets HWM floor
# "AMD:180", # Entry price $180 sets HWM floor
# "SHOP.TO:200", # Entry price $200 CAD sets HWM floor
# "NVDA", # No entry price - uses database HWM only
# ]

# Default stop-loss percentage (0-100)
# This is the percentage below the current price (simple) or high-water mark (trailing)
stop_loss_percentage = 5.0
Expand Down
4 changes: 4 additions & 0 deletions src/trailing_stop_loss/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class StopLossResult:
atr_value: float | None = None # ATR value (for ATR mode display)
atr_multiplier: float | None = None # ATR multiplier (for ATR mode display)
week_52_high: float | None = None # 52-week high (for display only)
entry_price: float | None = None # User-provided entry price (for display only)

@property
def formatted_guidance(self) -> str:
Expand Down Expand Up @@ -118,6 +119,7 @@ def calculate_simple(
dollar_risk=dollar_risk,
sma_50=sma_50,
week_52_high=base_price if base_price is not None else None,
entry_price=stock_price.entry_price,
)

def calculate_trailing(
Expand Down Expand Up @@ -173,6 +175,7 @@ def calculate_trailing(
dollar_risk=dollar_risk,
sma_50=sma_50,
week_52_high=None,
entry_price=stock_price.entry_price,
)

def calculate(
Expand Down Expand Up @@ -307,4 +310,5 @@ def calculate_atr_stop_loss(
atr_value=atr,
atr_multiplier=atr_multiplier,
week_52_high=base_price if base_price is not None else None,
entry_price=stock_price.entry_price,
)
48 changes: 45 additions & 3 deletions src/trailing_stop_loss/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,19 @@ def create_results_table(results: list[tuple[StockPrice, object]]) -> Table:
if not isinstance(result, Exception)
)

# Check if any results have entry price data
has_entry = any(
hasattr(result, "entry_price") and result.entry_price is not None
for _, result in results
if not isinstance(result, Exception)
)

table = Table(title="Stop-Loss Calculator Results", show_header=True, header_style="bold cyan")

table.add_column("Ticker", style="bold", justify="left")
table.add_column("Current Price", justify="right")
if has_entry:
table.add_column("Entry Price", justify="right")
if has_52week:
table.add_column("52-Week High", justify="right")
table.add_column("50-Day SMA", justify="right")
Expand All @@ -58,6 +67,8 @@ def create_results_table(results: list[tuple[StockPrice, object]]) -> Table:
stock_price.ticker if hasattr(stock_price, "ticker") else "?",
"[red]ERROR[/red]",
]
if has_entry:
row_data.append("[red]N/A[/red]")
if has_52week:
row_data.append("[red]N/A[/red]")
row_data.extend([
Expand Down Expand Up @@ -98,6 +109,11 @@ def create_results_table(results: list[tuple[StockPrice, object]]) -> Table:
result.ticker,
f"{result.currency} {result.current_price:.2f}",
]
if has_entry:
if result.entry_price is not None:
row_data.append(f"[magenta]{result.currency} {result.entry_price:.2f}[/magenta]")
else:
row_data.append("N/A")
if has_52week:
if result.week_52_high is not None:
row_data.append(f"[cyan]{result.currency} {result.week_52_high:.2f}[/cyan]")
Expand Down Expand Up @@ -182,8 +198,16 @@ def calculate(
# Load configuration
config = Config(config_file)

# Determine tickers
ticker_list = tickers if tickers else config.tickers
# Determine tickers (parse TICKER:PRICE format)
from trailing_stop_loss.config import parse_ticker_with_price

if tickers:
# Parse CLI arguments
ticker_list = [parse_ticker_with_price(t) for t in tickers]
else:
# Use config with entry prices
ticker_list = config.tickers_with_prices

if not ticker_list:
console.print(
"[red]No tickers specified. Add them to config.toml or pass as arguments."
Expand All @@ -210,6 +234,15 @@ def calculate(
# Default from config
use_mode = "trailing" if config.trailing_enabled else "simple"

# Validate entry prices only work with trailing mode
has_entry_prices = any(price is not None for _, price in ticker_list)
if has_entry_prices and use_mode != "trailing":
console.print(
"[red]Error: Entry prices (TICKER:PRICE format) only supported with trailing mode.[/red]"
)
console.print("[yellow]Use --trailing flag or enable trailing_enabled in config.toml[/yellow]")
raise typer.Exit(1)

# Parse since date if provided
since_date: date | None = None
if since:
Expand All @@ -227,7 +260,9 @@ def calculate(
# Fetch historical data if using trailing or ATR mode and history is enabled
if use_mode in ("trailing", "atr") and history_db:
console.print("[cyan]Updating historical price data...[/cyan]")
for ticker in ticker_list:
for ticker_item in ticker_list:
# Extract ticker from tuple (ticker, entry_price)
ticker = ticker_item[0] if isinstance(ticker_item, tuple) else ticker_item
try:
# Check if we have data
last_update = history_db.get_last_update_date(ticker)
Expand Down Expand Up @@ -355,6 +390,13 @@ def calculate(
hwm = None
if history_db:
hwm = history_db.get_high_water_mark(ticker, since_date)

# Entry price sets floor for high water mark
if hwm is not None and price_or_error.entry_price is not None:
hwm = max(hwm, price_or_error.entry_price)
elif hwm is None and price_or_error.entry_price is not None:
hwm = price_or_error.entry_price

stop_loss = calculator.calculate_trailing(
price_or_error, pct, high_water_mark=hwm, sma_50=sma_50
)
Expand Down
67 changes: 67 additions & 0 deletions src/trailing_stop_loss/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,55 @@
from typing import Any


def parse_ticker_with_price(ticker_str: str) -> tuple[str, float | None]:
"""Parse ticker string in format 'TICKER' or 'TICKER:PRICE'.

Args:
ticker_str: Ticker symbol, optionally with entry price (e.g., "AAPL:150.50")

Returns:
Tuple of (ticker, entry_price) where entry_price is None if not provided

Raises:
ValueError: If format is invalid or price is not positive

Examples:
>>> parse_ticker_with_price("AAPL")
('AAPL', None)
>>> parse_ticker_with_price("AAPL:150.50")
('AAPL', 150.5)
>>> parse_ticker_with_price("SHOP.TO:200")
('SHOP.TO', 200.0)
"""
ticker_str = ticker_str.strip()

if not ticker_str:
raise ValueError("Ticker string cannot be empty")

if ":" not in ticker_str:
return (ticker_str.upper(), None)

parts = ticker_str.split(":", 1)
ticker = parts[0].strip()
price_str = parts[1].strip()

if not ticker:
raise ValueError(f"Empty ticker in: {ticker_str}")

if not price_str:
raise ValueError(f"Empty price in: {ticker_str}")

try:
price = float(price_str)
except ValueError as e:
raise ValueError(f"Invalid price in '{ticker_str}': {price_str}") from e

if price <= 0:
raise ValueError(f"Entry price must be positive, got {price} in '{ticker_str}'")

return (ticker.upper(), price)


class Config:
"""Configuration for stop-loss calculations."""

Expand Down Expand Up @@ -37,6 +86,24 @@ def tickers(self) -> list[str]:
"""Get list of ticker symbols to track."""
return self._config.get("tickers", [])

@property
def tickers_with_prices(self) -> list[tuple[str, float | None]]:
"""Get list of (ticker, entry_price) tuples.

Parses tickers in format 'TICKER' or 'TICKER:PRICE'.

Returns:
List of (ticker, entry_price) tuples where entry_price is None
if not provided.

Examples:
>>> config._config = {"tickers": ["AAPL", "GOOGL:2800", "SHOP.TO:200"]}
>>> config.tickers_with_prices
[('AAPL', None), ('GOOGL', 2800.0), ('SHOP.TO', 200.0)]
"""
ticker_strs = self._config.get("tickers", [])
return [parse_ticker_with_price(t) for t in ticker_strs]

@property
def stop_loss_percentage(self) -> float:
"""Get default stop-loss percentage (0-100)."""
Expand Down
30 changes: 23 additions & 7 deletions src/trailing_stop_loss/fetcher.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Stock price fetching using yfinance."""

from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import date, datetime, timedelta

import pandas as pd
Expand All @@ -18,6 +18,7 @@ class StockPrice:
previous_close: float | None = None
week_52_high: float | None = None
week_52_low: float | None = None
entry_price: float | None = None # User-provided entry price


class PriceFetcher:
Expand All @@ -27,12 +28,15 @@ def __init__(self) -> None:
"""Initialize the price fetcher."""
self._cache: dict[str, StockPrice] = {}

def fetch_price(self, ticker: str, use_cache: bool = False) -> StockPrice:
def fetch_price(
self, ticker: str, use_cache: bool = False, entry_price: float | None = None
) -> StockPrice:
"""Fetch current price for a ticker symbol.

Args:
ticker: Stock ticker symbol (e.g., 'AAPL', 'GOOGL').
use_cache: Whether to use cached price if available.
entry_price: Optional user-provided entry price.

Returns:
StockPrice object with current price information.
Expand All @@ -41,7 +45,11 @@ def fetch_price(self, ticker: str, use_cache: bool = False) -> StockPrice:
ValueError: If ticker is invalid or price cannot be fetched.
"""
if use_cache and ticker in self._cache:
return self._cache[ticker]
cached = self._cache[ticker]
# Return a copy with updated entry price if provided
if entry_price is not None:
return replace(cached, entry_price=entry_price)
return cached

try:
stock = yf.Ticker(ticker)
Expand All @@ -65,6 +73,7 @@ def fetch_price(self, ticker: str, use_cache: bool = False) -> StockPrice:
previous_close=info.get("previousClose"),
week_52_high=info.get("fiftyTwoWeekHigh"),
week_52_low=info.get("fiftyTwoWeekLow"),
entry_price=entry_price,
)

self._cache[ticker] = stock_price
Expand All @@ -74,22 +83,29 @@ def fetch_price(self, ticker: str, use_cache: bool = False) -> StockPrice:
raise ValueError(f"Failed to fetch price for {ticker}: {e}") from e

def fetch_multiple(
self, tickers: list[str], skip_errors: bool = True
self, tickers: list[str | tuple[str, float | None]], skip_errors: bool = True
) -> dict[str, StockPrice | Exception]:
"""Fetch prices for multiple tickers.

Args:
tickers: List of ticker symbols.
tickers: List of ticker symbols or (ticker, entry_price) tuples.
skip_errors: If True, continue on errors; if False, raise on first error.

Returns:
Dictionary mapping tickers to StockPrice objects or Exceptions.
"""
results: dict[str, StockPrice | Exception] = {}

for ticker in tickers:
for item in tickers:
# Handle both formats: "AAPL" or ("AAPL", 150.0)
if isinstance(item, tuple):
ticker, entry_price = item
else:
ticker = item
entry_price = None

try:
results[ticker] = self.fetch_price(ticker)
results[ticker] = self.fetch_price(ticker, entry_price=entry_price)
except Exception as e:
if skip_errors:
results[ticker] = e
Expand Down
Loading