Skip to content

PortfolioManager API Reference

tradingbot.utils.portfolio_manager.PortfolioManager(bot_name: str, data_service: DataService, bot_repository: type[BotRepository], execution_config: ExecutionConfig | None = None)

Manages portfolio operations including buying, selling, and rebalancing.

Initialize portfolio manager.

No ORM row is ever kept on the instance. Writers lock the bot's row inside their own transaction and let it go with the session; readers take a plain dict (_portfolio). A row held across sessions is expired at commit and detached at close, and reading it raises DetachedInstanceError at whichever call happens to come next.

Parameters:

Name Type Description Default
bot_name str

Name of the bot whose bots row this manages

required
data_service DataService

DataService instance for fetching prices

required
bot_repository type[BotRepository]

BotRepository class (used via its staticmethods, never instantiated)

required
execution_config ExecutionConfig | None

Costs and no-trade band. Injected rather than read from the module singleton so tests can vary it: this module did from .config import EXECUTION_CONFIG, binding the reference at import, so monkeypatching config.EXECUTION_CONFIG would be silently invisible here. Bots override via env vars instead.

None
Source code in tradingbot/utils/portfolio_manager.py
def __init__(
    self,
    bot_name: str,
    data_service: DataService,
    bot_repository: type[BotRepository],
    execution_config: ExecutionConfig | None = None,
):
    """
    Initialize portfolio manager.

    No ORM row is ever kept on the instance. Writers lock the bot's row
    inside their own transaction and let it go with the session; readers
    take a plain dict (_portfolio). A row held across sessions is expired
    at commit and detached at close, and reading it raises
    DetachedInstanceError at whichever call happens to come next.

    Args:
        bot_name: Name of the bot whose `bots` row this manages
        data_service: DataService instance for fetching prices
        bot_repository: BotRepository class (used via its staticmethods, never instantiated)
        execution_config: Costs and no-trade band. Injected rather than read
            from the module singleton so tests can vary it: this module did
            `from .config import EXECUTION_CONFIG`, binding the reference at
            import, so monkeypatching config.EXECUTION_CONFIG would be
            silently invisible here. Bots override via env vars instead.
    """
    self.bot_name = bot_name
    self.data_service = data_service
    self.bot_repository = bot_repository
    self.execution_config = execution_config or EXECUTION_CONFIG

assign_before_dividends(physical: bool, today=None) -> list[str]

Early assignment of short ITM calls whose extrinsic value is below the next dividend, on the session before the ex-date (or the ex-date itself, if the bot did not run the day before). Returns the keys assigned.

Source code in tradingbot/utils/portfolio_manager.py
def assign_before_dividends(self, physical: bool, today=None) -> list[str]:
    """
    Early assignment of short ITM calls whose extrinsic value is below the
    next dividend, on the session before the ex-date (or the ex-date itself,
    if the bot did not run the day before). Returns the keys assigned.
    """
    today = today or options.utc_today()
    assigned = []
    for key, qty in options.option_legs(self._portfolio()).items():
        contract = options.parse_occ(key)
        if qty >= 0 or contract.right != "C" or contract.expiry < today:
            continue
        div = options.next_dividend(contract.underlying, today)
        if div is None or not 0 <= options.business_days_between(today, div[0]) <= 1:
            continue
        try:
            spot = self.data_service.get_latest_price(contract.underlying)
            mark = self.data_service.get_latest_price(key)
        except Exception as e:
            logger.warning("Early-assignment check for %s skipped: %s", key, e)
            continue
        intrinsic = options.intrinsic_value(contract, spot)
        if not om.early_exercise_likely(mark - intrinsic, div[1], intrinsic):
            continue
        logger.info(
            "EARLY ASSIGNMENT %s: ex-dividend %s pays %.2f, extrinsic only %.2f",
            key,
            div[0],
            div[1],
            mark - intrinsic,
        )
        if physical:
            self._exercise(key, contract, intrinsic, "assigned early")
        else:
            self._cash_settle(key, intrinsic, "assigned early")
        assigned.append(key)
    return assigned

buy(symbol: str, quantity_usd: float = -1, cached_data: pd.DataFrame | None = None, session: Session | None = None, option: bool | str | None = None, target_dte: int = 30, target_delta: float | None = None) -> None

Buy a quantity of the specified symbol.

Parameters:

Name Type Description Default
symbol str

Trading symbol to buy — for options, the UNDERLYING

required
quantity_usd float

Amount in USD to spend (-1 means use all available cash)

-1
cached_data DataFrame | None

Optional cached DataFrame for price lookup

None
session Session | None

Optional existing database session

None
option bool | str | None

None/False buys the symbol itself. True / "call" / "put" buys a contract on it instead, chosen by options.select_contract (first expiry >= target_dte days out, strike nearest spot).

None
target_dte int

Minimum days to expiry for the selected contract.

30
target_delta float | None

Pick the strike nearest this delta (e.g. 0.70) instead of the one nearest spot.

None

Cash reserved as margin for short option legs (see options.margin_requirement) is never spendable here. That is 0 for any bot without short options.

Source code in tradingbot/utils/portfolio_manager.py
def buy(
    self,
    symbol: str,
    quantity_usd: float = -1,
    cached_data: pd.DataFrame | None = None,
    session: Session | None = None,
    option: bool | str | None = None,
    target_dte: int = 30,
    target_delta: float | None = None,
) -> None:
    """
    Buy a quantity of the specified symbol.

    Args:
        symbol: Trading symbol to buy — for options, the UNDERLYING
        quantity_usd: Amount in USD to spend (-1 means use all available cash)
        cached_data: Optional cached DataFrame for price lookup
        session: Optional existing database session
        option: None/False buys the symbol itself. True / "call" / "put"
            buys a contract on it instead, chosen by options.select_contract
            (first expiry >= target_dte days out, strike nearest spot).
        target_dte: Minimum days to expiry for the selected contract.
        target_delta: Pick the strike nearest this delta (e.g. 0.70)
            instead of the one nearest spot.

    Cash reserved as margin for short option legs (see
    options.margin_requirement) is never spendable here. That is 0 for any
    bot without short options.
    """
    right = options.normalize_right(option)
    if right is None and options.is_option_symbol(symbol):
        # Contract strings are an internal key, not the bot-facing API: a bot
        # that has one in hand got it from its own portfolio, and buying it
        # back directly would skip contract selection and liquidity checks.
        raise ValueError(f"Buy options by underlying: buy({options.parse_occ(symbol).underlying!r}, option=...)")
    if right is not None:
        # Resolved BEFORE any row lock: selection is several yfinance calls.
        try:
            spot = self.data_service.get_latest_price(symbol)
        except Exception:
            spot = None  # select_contract falls back to the chain's own spot
        symbol = options.select_contract(symbol, right, target_dte, spot=spot, delta=target_delta)

    def _execute_buy(sess: Session):
        row = self.bot_repository.get_bot_locked(sess, self.bot_name)
        cfg = self.execution_config
        cash = row.portfolio.get("USD", 0)
        spendable = cash - options.margin_requirement(row.portfolio)

        # `quantity_usd` is the GROSS cash budget; commission comes out of it
        # rather than on top. That is what makes the spend-all-cash case
        # incapable of overdrawing at any commission rate: the debit is the
        # budget itself, so USD lands on exactly 0.0.
        qty_usd = spendable if quantity_usd == -1 else quantity_usd

        if qty_usd > spendable:
            # A fully-invested rebalance routinely lands here by a few bps,
            # because sells now raise slightly less than their notional. Only
            # a materially short buy is worth a warning.
            shortfall = qty_usd - spendable
            level = logging.INFO if shortfall <= max(1.0, 0.01 * qty_usd) else logging.WARNING
            logger.log(
                level,
                "Trimming buy of %s to available cash: have $%.2f, wanted $%.2f",
                symbol,
                spendable,
                qty_usd,
            )
            qty_usd = spendable

        if qty_usd <= 0:
            logger.warning(f"Insufficient cash to buy {symbol}")
            return

        price = self.data_service.get_latest_price(symbol, cached_data)
        if price <= 0:
            # Guard the division: a ZeroDivisionError raised inside
            # rebalance_portfolio aborts the locked transaction after the
            # sells have run but before the buys, leaving the book in cash.
            logger.warning("Non-positive price %s for %s; skipping buy", price, symbol)
            return

        commission_cost = qty_usd * cfg.commission_pct
        available = qty_usd - commission_cost
        debit = qty_usd
        if options.is_option_symbol(symbol):
            execution_price = self._option_fill(symbol, price, is_buy=True)
            quantity = options.whole_contract_qty(available / execution_price)
            if quantity <= 0:
                logger.warning(
                    "$%.2f buys less than one %s contract (%.2f per contract); skipping",
                    qty_usd,
                    symbol,
                    execution_price * options.CONTRACT_MULTIPLIER,
                )
                return
            # Whole contracts leave change: debit only what was spent. Still
            # <= qty_usd, since quantity was floored from the post-commission budget.
            commission_cost = quantity * execution_price * cfg.commission_pct
            debit = quantity * execution_price + commission_cost
        else:
            execution_price = cfg.buy_execution_price(price)
            quantity = available / execution_price

        if quantity <= 0:
            logger.warning(f"Calculated quantity for {symbol} is <= 0")
            return

        portfolio = row.portfolio.copy()
        portfolio["USD"] = cash - debit  # stocks: the full gross budget; options: exact spend
        portfolio[symbol] = portfolio.get(symbol, 0) + quantity

        row.portfolio = portfolio
        self.bot_repository.update_bot(row, session=sess)
        self.bot_repository.log_trade(
            bot_name=self.bot_name,
            symbol=symbol,
            quantity=quantity,
            # Execution price, not the reference price: quantity * price must
            # explain the cash movement. The reference price stays recoverable
            # from historic_data; the execution price is recorded nowhere else.
            price=execution_price,
            is_buy=True,
            session=sess,
        )
        logger.info(
            "BOUGHT %.6f of %s at %.4f (ref %.4f, commission %.4f) for gross %.2f",
            quantity,
            symbol,
            execution_price,
            price,
            commission_cost,
            debit,
        )

    if session:
        _execute_buy(session)
    else:
        with get_db_session() as sess:
            _execute_buy(sess)

close_options(underlying: str, session: Session | None = None, include_stock: bool = False) -> float

Flatten every option leg on underlying, long and short, in one transaction. include_stock also flattens the shares held beside them (the hedge of a straddle, the stock of a collar).

Source code in tradingbot/utils/portfolio_manager.py
def close_options(self, underlying: str, session: Session | None = None, include_stock: bool = False) -> float:
    """
    Flatten every option leg on `underlying`, long and short, in one
    transaction. include_stock also flattens the shares held beside them
    (the hedge of a straddle, the stock of a collar).
    """
    portfolio = self._portfolio(session)
    legs = options.option_legs(portfolio, underlying)
    shares = float(portfolio.get(underlying, 0.0) or 0.0) if include_stock else 0.0
    if not legs:
        return self._flatten_stock(underlying, shares, session) if abs(shares) > 1e-9 else 0.0
    if abs(shares) > 1e-9:
        legs = {**legs, underlying: shares}
    return self.trade_option_legs([(k, -q) for k, q in legs.items()], session=session)

delta_hedge(underlying: str, band_usd: float, ww: tuple[float, float] | None = None) -> float

Trade whole shares so the net delta of the options plus shares on underlying returns to about zero, once its dollar value exceeds band_usd. Returns the shares traded (signed). Gamma scalping is this, run every day on a long straddle: it sells rallies and buys dips.

ww=(cost_frac, risk_aversion) uses the Whalley-Wilmott band instead (option_rules.hedge_trade): rehedge only outside it, and only to its edge.

Source code in tradingbot/utils/portfolio_manager.py
def delta_hedge(self, underlying: str, band_usd: float, ww: tuple[float, float] | None = None) -> float:
    """
    Trade whole shares so the net delta of the options plus shares on
    `underlying` returns to about zero, once its dollar value exceeds
    `band_usd`. Returns the shares traded (signed). Gamma scalping is this,
    run every day on a long straddle: it sells rallies and buys dips.

    ww=(cost_frac, risk_aversion) uses the Whalley-Wilmott band instead
    (option_rules.hedge_trade): rehedge only outside it, and only to its edge.
    """
    from .option_rules import delta_hedge_shares

    book = self.option_book(underlying)
    if book.empty:
        return 0.0
    net = book.net_delta
    shares = delta_hedge_shares(net, book.spot, book.greeks.gamma, band_usd, ww)
    if shares == 0:
        return 0.0
    self.trade_option_legs([(underlying, shares)])
    logger.info("Delta hedge %s: net delta %.1f shares -> traded %+.0f", underlying, net, shares)
    return shares

open_structure(pick: options.StructurePick, max_risk_usd: float) -> int

Open as many units of pick as max_risk_usd of worst-case loss allows.

One unit is one contract per option leg, 100 shares per stock leg (signs from the pick). Max loss per unit is taken from the prices it would fill at (ask on the long legs, bid on the short), so for a credit spread it is width - credit, for a collar stock - put strike + net debit. Refuses when the chain was not live: off-hours last prices on different legs are from different moments, and a "credit" built from them is fiction.

Returns:

Type Description
int

Units opened (0 when refused or unaffordable).

Source code in tradingbot/utils/portfolio_manager.py
def open_structure(self, pick: options.StructurePick, max_risk_usd: float) -> int:
    """
    Open as many units of `pick` as `max_risk_usd` of worst-case loss allows.

    One unit is one contract per option leg, 100 shares per stock leg (signs
    from the pick). Max loss per unit is taken from the prices it would fill
    at (ask on the long legs, bid on the short), so for a credit spread it
    is width - credit, for a collar stock - put strike + net debit. Refuses when
    the chain was not live: off-hours last prices on different legs are from
    different moments, and a "credit" built from them is fiction.

    Returns:
        Units opened (0 when refused or unaffordable).
    """
    if not pick.live:
        logger.warning("Not opening %s structure off-hours (no live chain)", pick.underlying)
        return 0
    unit_legs = []
    for key, unit in pick.legs:
        fill = self._leg_fill(key, self.data_service.get_latest_price(key), is_buy=unit > 0)
        if options.is_option_symbol(key):
            c = options.parse_occ(key)
            unit_legs.append(om.Leg(c.right, c.strike, unit, fill))
        else:
            unit_legs.append(om.stock_leg(unit, fill))
    per_unit = om.max_loss(unit_legs) * options.CONTRACT_MULTIPLIER
    if math.isinf(per_unit):
        raise ValueError(f"Refusing unbounded-risk structure {pick.legs}")
    if per_unit <= 0:
        logger.warning("Structure %s shows no risk at fill prices (bad quotes?); skipping", pick.legs)
        return 0

    portfolio = self._portfolio()
    free = portfolio.get("USD", 0) - options.margin_requirement(portfolio)
    units = options.units_for_risk(per_unit, max_risk_usd, free)
    if units < 1:
        logger.warning(
            "One %s unit risks $%.2f; budget $%.2f, free cash $%.2f — skipping",
            pick.underlying,
            per_unit,
            max_risk_usd,
            free,
        )
        return 0
    self.trade_option_legs([(k, u * units * options.CONTRACT_MULTIPLIER) for k, u in pick.legs])
    logger.info("Opened %d x %s (max loss $%.2f each)", units, [k for k, _ in pick.legs], per_unit)
    return units

option_book(underlying: str) -> options.OptionBook

Positions, marks, entry value, P&L and net greeks of the options on underlying.

Source code in tradingbot/utils/portfolio_manager.py
def option_book(self, underlying: str) -> options.OptionBook:
    """Positions, marks, entry value, P&L and net greeks of the options on `underlying`."""
    portfolio = self._portfolio()
    legs = options.option_legs(portfolio, underlying)
    spot = self.data_service.get_latest_price(underlying)
    prices = {k: self.data_service.get_latest_price(k) for k in legs}
    entries = {k: options.entry_value(self.bot_name, k) for k in legs}
    shares = float(portfolio.get(underlying, 0.0) or 0.0)
    return options.build_book(underlying, legs, prices, spot, entries, shares=shares)

rebalance_portfolio(target_portfolio: dict[str, float], only_over_50_usd: bool = False) -> None

Rebalance portfolio to match target weights in a single transaction with row locking.

Parameters:

Name Type Description Default
target_portfolio dict[str, float]

Dictionary mapping symbols to target weights (e.g., {"VWCE": 0.8, "GLD": 0.1, "USD": 0.1}) Weights must sum to 1.0 (100%)

required
only_over_50_usd bool

If True, filter out assets with target value <= $50

False
Source code in tradingbot/utils/portfolio_manager.py
def rebalance_portfolio(self, target_portfolio: dict[str, float], only_over_50_usd: bool = False) -> None:
    """
    Rebalance portfolio to match target weights in a single transaction with row locking.

    Args:
        target_portfolio: Dictionary mapping symbols to target weights (e.g., {"VWCE": 0.8, "GLD": 0.1, "USD": 0.1})
                       Weights must sum to 1.0 (100%)
        only_over_50_usd: If True, filter out assets with target value <= $50
    """
    # Step 1: Validate weights sum to 1.0. Caller-supplied input, so reject
    # rather than silently rescale — a target that does not sum to 1 means the
    # caller's own maths is wrong and rescaling would hide it.
    require_normalized(target_portfolio)
    contracts = [s for s in target_portfolio if options.is_option_symbol(s)]
    if contracts:
        # Weights are per symbol; an option target would need contract
        # selection and rolling inside the rebalance. Not supported — held
        # contracts absent from the target are sold like any other exit.
        raise ValueError(f"rebalancePortfolio cannot target option contracts {contracts}; use buy/sell(option=...)")
    shorts = [k for k, q in self._portfolio().items() if k != "USD" and q < -1e-9]
    if shorts:
        # A weight-based rebalance values and exits long holdings; a short leg
        # would be ignored in the total and "exited" via buy() of a raw
        # contract. Spread books are managed with close_options instead.
        raise ValueError(f"rebalancePortfolio cannot run on a book with short option legs {shorts}")

    # Step 2: Resolve prices BEFORE taking the row lock.
    #
    # get_latest_prices_batch opens its own session and, on a cache miss, calls
    # yfinance over the network. Doing that inside the locked block held
    # SELECT ... FOR UPDATE on this bot's row across an external HTTP request,
    # so every other writer for the same bot — the copier, the worth
    # calculator, a concurrent run — blocked for as long as a third party took
    # to respond, with the statement timeout as the only ceiling.
    preview_symbols = sorted(set(target_portfolio) | set(self._portfolio()))
    prices = self.data_service.get_latest_prices_batch([s for s in preview_symbols if s != "USD"])

    with get_db_session() as session:
        # Lock bot row for the entire duration of rebalance
        row = self.bot_repository.get_bot_locked(session, self.bot_name)

        # Step 3: Calculate current portfolio value
        current_usd = row.portfolio.get("USD", 0)

        # Get all symbols involved. Sorted, not set-ordered: buys are sized
        # against the pre-trade snapshot, so whichever symbol comes last
        # absorbs any cash shortfall. Hash-order would make that symbol vary
        # between processes — invisible before costs existed, visible now.
        all_involved_symbols = sorted(set(list(target_portfolio.keys()) + list(row.portfolio.keys())))
        all_involved_symbols = [s for s in all_involved_symbols if s != "USD"]

        # The unlocked snapshot above can be stale: another writer may have
        # added a holding between the two reads. Fetch only what that missed,
        # so the common case still costs zero network time under the lock.
        missing = [s for s in all_involved_symbols if s not in prices]
        if missing:
            logger.debug(f"Prices for {missing} appeared after the pre-lock snapshot; fetching under lock")
            prices.update(self.data_service.get_latest_prices_batch(missing))

        # Calculate total portfolio value
        total_portfolio_value = current_usd
        current_values = {"USD": current_usd}

        for symbol in all_involved_symbols:
            qty = row.portfolio.get(symbol, 0)
            if qty > 0:
                price = prices.get(symbol)
                if price:
                    val = qty * price
                    current_values[symbol] = val
                    total_portfolio_value += val
                else:
                    logger.warning(f"Could not get price for {symbol}, assuming zero value")
                    current_values[symbol] = 0

        if total_portfolio_value <= 0:
            logger.warning("Portfolio worth is zero, cannot rebalance")
            return

        # Step 3: Apply $50 threshold if requested
        actual_targets = target_portfolio.copy()
        if only_over_50_usd:
            filtered_weights = {}
            excluded_weight = 0.0

            for sym, weight in actual_targets.items():
                if sym == "USD" or (weight * total_portfolio_value) > PORTFOLIO_CONFIG.min_asset_value_usd:
                    filtered_weights[sym] = weight
                else:
                    excluded_weight += weight

            if excluded_weight > 0:
                # Redistribute to remaining non-USD assets
                non_usd_remaining = [s for s in filtered_weights if s != "USD"]
                if non_usd_remaining:
                    redist_per_asset = excluded_weight / len(non_usd_remaining)
                    for s in non_usd_remaining:
                        filtered_weights[s] += redist_per_asset
                    actual_targets = filtered_weights
                else:
                    # Put all in USD if no assets left
                    actual_targets = {"USD": 1.0}

        # Step 4: Calculate target values and differences
        target_values = {s: total_portfolio_value * w for s, w in actual_targets.items()}

        trades_to_sell = {}  # symbol -> USD amount
        trades_to_buy = {}

        cfg = self.execution_config
        skipped = 0
        skipped_usd = 0.0

        for symbol in all_involved_symbols:
            target_val = target_values.get(symbol, 0)
            current_val = current_values.get(symbol, 0)
            diff = target_val - current_val

            # No target but a live position == full liquidation, never banded.
            # This is also how the min_asset_value_usd filter above expresses
            # "close this position": it drops the symbol from actual_targets.
            is_full_exit = target_val <= 0 < current_val
            reference_val = max(target_val, current_val)

            if not should_trade(diff, reference_val, cfg, is_full_exit=is_full_exit):
                if abs(diff) > 0:
                    skipped += 1
                    skipped_usd += abs(diff)
                continue

            if diff < 0:
                trades_to_sell[symbol] = abs(diff)
            else:
                trades_to_buy[symbol] = diff

        logger.info(
            "Rebalancing %s: Total Value $%.2f, %d sells, %d buys, "
            "%d skipped inside no-trade band ($%.2f notional; band = max($%.2f, %.1f%% of position))",
            self.bot_name,
            total_portfolio_value,
            len(trades_to_sell),
            len(trades_to_buy),
            skipped,
            skipped_usd,
            cfg.min_trade_usd,
            cfg.rebalance_band_pct * 100,
        )

        # Step 5: Execute trades (Sells first)
        for symbol, usd_amt in trades_to_sell.items():
            self.sell(symbol, quantity_usd=usd_amt, session=session)

        # Re-read cash after sells
        for symbol, usd_amt in trades_to_buy.items():
            self.buy(symbol, quantity_usd=usd_amt, session=session)

        logger.info("Rebalance complete")

roll_and_settle_options(roll_dte: int | None, target_dte: int, target_delta: float | None = None, settlement: str = 'cash') -> None

Keep option positions alive without the bot having to think about expiry.

  • Contracts already past expiry are settled off the underlying's close on the expiry date, long and short alike. settlement="cash" pays the intrinsic value. settlement="physical" does what a broker does: an ITM contract becomes shares at the strike (a short put is assigned 100 shares per contract, a short call delivers them), except any part that would leave the book short stock, which is cash-settled instead.
  • Long contracts with <= roll_dte days left are sold and the proceeds rebought in a fresh contract on the same underlying and side (at target_delta if given), so "I hold an AAPL call" stays true across expiries. roll_dte=None disables rolling.
  • An underlying with any SHORT leg is never rolled: rolling one wing of a spread alone would leave the short side uncovered. Strategies that sell options manage their own exits.
  • A short ITM call is assigned EARLY on the last session before its underlying goes ex-dividend when its extrinsic value is below the dividend: that is when a rational holder exercises to collect it (assign_before_dividends). Physical bots deliver shares, cash bots settle at intrinsic. Bots avoid it by closing the call first (option_rules.ex_dividend_close_reason).

A no-op for a bot holding no options.

Source code in tradingbot/utils/portfolio_manager.py
def roll_and_settle_options(
    self,
    roll_dte: int | None,
    target_dte: int,
    target_delta: float | None = None,
    settlement: str = "cash",
) -> None:
    """
    Keep option positions alive without the bot having to think about expiry.

    - Contracts already past expiry are settled off the underlying's close on
      the expiry date, long and short alike. settlement="cash" pays the
      intrinsic value. settlement="physical" does what a broker does: an ITM
      contract becomes shares at the strike (a short put is assigned 100
      shares per contract, a short call delivers them), except any part that
      would leave the book short stock, which is cash-settled instead.
    - Long contracts with <= roll_dte days left are sold and the proceeds
      rebought in a fresh contract on the same underlying and side (at
      target_delta if given), so "I hold an AAPL call" stays true across
      expiries. roll_dte=None disables rolling.
    - An underlying with any SHORT leg is never rolled: rolling one wing of
      a spread alone would leave the short side uncovered. Strategies that
      sell options manage their own exits.
    - A short ITM call is assigned EARLY on the last session before its
      underlying goes ex-dividend when its extrinsic value is below the
      dividend: that is when a rational holder exercises to collect it
      (assign_before_dividends). Physical bots deliver shares, cash bots
      settle at intrinsic. Bots avoid it by closing the call first
      (option_rules.ex_dividend_close_reason).

    A no-op for a bot holding no options.
    """
    today = options.utc_today()
    for key in options.option_legs(self._portfolio()):
        contract = options.parse_occ(key)
        if contract.expiry < today:
            self._settle_expired(key, contract, physical=settlement == "physical")
    self.assign_before_dividends(physical=settlement == "physical", today=today)
    if roll_dte is None:
        return

    portfolio = self._portfolio()
    shorted = {options.parse_occ(k).underlying for k, q in options.option_legs(portfolio).items() if q < 0}
    for key in options.held_option_keys(portfolio):
        contract = options.parse_occ(key)
        if contract.underlying in shorted or (contract.expiry - today).days > roll_dte:
            continue
        proceeds = self.sell(key)
        if proceeds <= 0:
            continue
        try:
            self.buy(
                contract.underlying,
                proceeds,
                option=contract.right,
                target_dte=target_dte,
                target_delta=target_delta,
            )
        except Exception as e:
            # The sell already committed; the proceeds simply stay cash.
            logger.warning("Rolled out of %s but could not roll into a new contract: %s", key, e)

sell(symbol: str, quantity_usd: float = -1, cached_data: pd.DataFrame | None = None, session: Session | None = None, option: bool | str | None = None) -> float

Sell a quantity of the specified symbol.

Parameters:

Name Type Description Default
symbol str

Trading symbol to sell — for options, the UNDERLYING

required
quantity_usd float

Amount in USD to sell (-1 means sell all holdings)

-1
cached_data DataFrame | None

Optional cached DataFrame for price lookup

None
session Session | None

Optional existing database session

None
option bool | str | None

None/False sells the symbol itself. True sells every option held on it; "call" / "put" only that side. Earliest expiry first.

None

Returns:

Type Description
float

Net cash credited (0.0 if nothing was sold).

Source code in tradingbot/utils/portfolio_manager.py
def sell(
    self,
    symbol: str,
    quantity_usd: float = -1,
    cached_data: pd.DataFrame | None = None,
    session: Session | None = None,
    option: bool | str | None = None,
) -> float:
    """
    Sell a quantity of the specified symbol.

    Args:
        symbol: Trading symbol to sell — for options, the UNDERLYING
        quantity_usd: Amount in USD to sell (-1 means sell all holdings)
        cached_data: Optional cached DataFrame for price lookup
        session: Optional existing database session
        option: None/False sells the symbol itself. True sells every option
            held on it; "call" / "put" only that side. Earliest expiry first.

    Returns:
        Net cash credited (0.0 if nothing was sold).
    """
    if option is not None and option is not False:
        right = None if option is True else options.normalize_right(option)
        return self._sell_options(symbol, right, quantity_usd, session)

    def _execute_sell(sess: Session) -> float:
        row = self.bot_repository.get_bot_locked(sess, self.bot_name)
        cfg = self.execution_config
        holding = row.portfolio.get(symbol, 0)
        if holding <= 0:
            logger.warning(f"No holdings of {symbol} to sell")
            return 0.0

        price = self.data_service.get_latest_price(symbol, cached_data)
        if price <= 0:
            logger.warning("Non-positive price %s for %s; skipping sell", price, symbol)
            return 0.0
        is_option = options.is_option_symbol(symbol)

        # On a sell, `quantity_usd` is the POSITION NOTIONAL to shed valued at
        # the reference price — not the cash to raise. rebalance_portfolio
        # computes it as (current_value - target_value) from a mid-price
        # snapshot, so sizing the share count off that same price is what lands
        # the post-trade position exactly on target and converges in one step.
        # Sizing off the execution price instead would shed ~5bps too many
        # shares, undershoot, and hand the next rebalance a fresh diff to
        # correct — generating precisely the churn this model exists to remove.
        quantity = holding if quantity_usd == -1 else quantity_usd / price

        if quantity > holding:
            logger.warning(f"Insufficient holdings of {symbol} to sell requested amount. Selling all.")
            quantity = holding
        if is_option and quantity < holding:
            quantity = options.whole_contract_qty(quantity)

        if quantity <= 0:
            return 0.0

        # Proceeds are derived AFTER the clamp, so the clamp stays a pure
        # share-count comparison that slippage cannot influence.
        if is_option:
            execution_price = self._option_fill(symbol, price, is_buy=False)
        else:
            execution_price = cfg.sell_execution_price(price)
        gross_proceeds = quantity * execution_price
        commission_cost = gross_proceeds * cfg.commission_pct
        net_proceeds = gross_proceeds - commission_cost

        portfolio = row.portfolio.copy()
        portfolio["USD"] = portfolio.get("USD", 0) + net_proceeds
        portfolio[symbol] = holding - quantity

        # Remove zero holdings
        if portfolio[symbol] <= 0.000001:
            del portfolio[symbol]

        # Shares under a short call (a covered call, a collar) are part of a
        # structure: selling them would leave the call naked. The margin of
        # the resulting book says whether they are still needed.
        if not is_option and options.option_legs(portfolio, symbol):
            required = options.margin_requirement(portfolio)
            if portfolio["USD"] + 1e-6 < required:
                logger.warning(
                    "Refused selling %s %s: it covers option legs (margin $%.2f > cash $%.2f); "
                    "close the structure instead",
                    quantity,
                    symbol,
                    required,
                    portfolio["USD"],
                )
                return 0.0

        row.portfolio = portfolio
        self.bot_repository.update_bot(row, session=sess)
        self.bot_repository.log_trade(
            bot_name=self.bot_name,
            symbol=symbol,
            quantity=quantity,
            price=execution_price,
            is_buy=False,
            profit=net_proceeds,  # net cash credited, NOT realized P&L
            session=sess,
        )
        logger.info(
            "SOLD %.6f of %s at %.4f (ref %.4f, commission %.4f) for net proceeds %.2f",
            quantity,
            symbol,
            execution_price,
            price,
            commission_cost,
            net_proceeds,
        )
        return net_proceeds

    if session:
        return _execute_sell(session)
    with get_db_session() as sess:
        return _execute_sell(sess)

total_value() -> float

Cash plus every holding (short option legs negative) at the latest price.

Source code in tradingbot/utils/portfolio_manager.py
def total_value(self) -> float:
    """Cash plus every holding (short option legs negative) at the latest price."""
    portfolio = self._portfolio()
    held = [s for s, q in portfolio.items() if s != "USD" and abs(q) > 1e-6]
    prices = self.data_service.get_latest_prices_batch(held) if held else {}
    return portfolio.get("USD", 0) + sum(portfolio[s] * prices.get(s, 0.0) for s in held)

trade_option_legs(legs: list[tuple[str, float]], session: Session | None = None) -> float

Change several option positions in ONE locked transaction.

Parameters:

Name Type Description Default
legs list[tuple[str, float]]

(symbol, signed share-equivalent change). For an OCC contract +200 buys two contracts (to open or to close a short), -200 sells two (to close a long or to open a short); whole contracts only. The underlying's own symbol trades shares — allowed only when the trade or the book holds options on it (buy-writes, collars, delta hedges), and the only way to go short stock.

required

Returns:

Type Description
float

Net cash flow: positive for a credit, negative for a debit.

This is the only path that can make a holding negative. Each option leg fills at the ask when buying and the bid when selling (last price +/- option slippage without a market); stock legs use the stock cost model. If the trade opens or grows any position, it is refused as a whole — nothing is written — when the cash left would not cover options.margin_requirement of the resulting book. That also refuses anything with unbounded risk, such as a naked short call. Pure reductions always go through, so a position can always be closed.

Source code in tradingbot/utils/portfolio_manager.py
def trade_option_legs(self, legs: list[tuple[str, float]], session: Session | None = None) -> float:
    """
    Change several option positions in ONE locked transaction.

    Args:
        legs: (symbol, signed share-equivalent change). For an OCC contract
            +200 buys two contracts (to open or to close a short), -200
            sells two (to close a long or to open a short); whole contracts
            only. The underlying's own symbol trades shares — allowed only
            when the trade or the book holds options on it (buy-writes,
            collars, delta hedges), and the only way to go short stock.

    Returns:
        Net cash flow: positive for a credit, negative for a debit.

    This is the only path that can make a holding negative. Each option leg
    fills at the ask when buying and the bid when selling (last price +/-
    option slippage without a market); stock legs use the stock cost model. If the trade opens or grows any position,
    it is refused as a whole — nothing is written — when the cash left
    would not cover options.margin_requirement of the resulting book. That
    also refuses anything with unbounded risk, such as a naked short call.
    Pure reductions always go through, so a position can always be closed.
    """
    legs = [(k, float(q)) for k, q in legs if abs(q) > 1e-9]
    if not legs:
        return 0.0
    traded_underlyings = {options.parse_occ(k).underlying for k, _ in legs if options.is_option_symbol(k)}
    stock_keys = [k for k, _ in legs if not options.is_option_symbol(k)]
    for key, qty in legs:
        if key == "USD":
            raise ValueError("trade_option_legs cannot trade USD")
        if options.is_option_symbol(key) and options.whole_contract_qty(abs(qty)) != round(abs(qty)):
            raise ValueError(f"{key}: {qty} is not a whole number of contracts")
    # Reference prices before the row lock: pricing may refetch a chain.
    refs = {key: self.data_service.get_latest_price(key) for key, _ in legs}

    def _execute(sess: Session) -> float:
        row = self.bot_repository.get_bot_locked(sess, self.bot_name)
        cfg = self.execution_config
        portfolio = row.portfolio.copy()
        allowed = traded_underlyings | options.option_underlyings(portfolio)
        naked = [k for k in stock_keys if k not in allowed]
        if naked:
            raise ValueError(f"trade_option_legs trades stock only beside options on it, got {naked}")
        cash = portfolio.get("USD", 0)
        fills = []
        grows = False
        for key, qty in legs:
            is_buy = qty > 0
            price = self._leg_fill(key, refs[key], is_buy=is_buy)
            notional = abs(qty) * price
            flow = (-notional if is_buy else notional) - notional * cfg.commission_pct
            old = portfolio.get(key, 0.0)
            new = old + qty
            grows = grows or abs(new) > abs(old) + 1e-9
            if abs(new) < 1e-6:
                portfolio.pop(key, None)
            else:
                portfolio[key] = new
            cash += flow
            fills.append((key, qty, price, flow))
        portfolio["USD"] = cash

        required = options.margin_requirement(portfolio)
        if grows and cash + 1e-6 < required:
            raise ValueError(
                f"Refused option trade {[(k, q) for k, q in legs]}: cash after ${cash:,.2f} "
                f"< margin required ${required:,.2f}"
            )

        row.portfolio = portfolio
        self.bot_repository.update_bot(row, session=sess)
        for key, qty, price, flow in fills:
            self.bot_repository.log_trade(
                bot_name=self.bot_name,
                symbol=key,
                quantity=abs(qty),
                price=price,
                is_buy=qty > 0,
                profit=flow if qty < 0 else None,
                session=sess,
            )
            logger.info(
                "%s %.0f of %s at %.4f (cash %+.2f)", "BOUGHT" if qty > 0 else "SOLD", abs(qty), key, price, flow
            )
        return sum(f[3] for f in fills)

    if session:
        return _execute(session)
    with get_db_session() as sess:
        return _execute(sess)