DEV Community

mattleeee
mattleeee

Posted on Originally published at hkcode.dpdns.org

Partial Take-Profit: Bank Half at 3%, Let the Rest Run

The Problem With a Single Take-Profit

A fixed take-profit forces a binary choice. Set it tight and you cut winners that would have kept running. Set it wide and you watch a 5% open profit evaporate back to zero because the target was never hit. Both outcomes come from the same design flaw: the exit is a single point, and the market gets one vote on whether you keep everything.

Partial take-profit splits that vote. The version I run in the paper trader: when unrealized profit on the open position reaches 3%, sell half the position and move the stop on the remainder to break-even. The remaining half targets the original 6%. The first leg banks a guaranteed gain; the second leg keeps upside exposure at zero risk (modulo fees, which we'll get to).

The strategy logic is trivial. The bookkeeping is where implementations break, so most of this post is about state.

Position State

Every open position needs enough state to answer: how much is left, what did it cost, what's been realized, and has the partial already fired. A dataclass is the cleanest container:

from dataclasses import dataclass, field

@dataclass
class Position:
    entry_price: float
    qty: float
    stop_price: float
    target_price: float
    partial_level: float
    partial_fraction: float = 0.5
    partial_done: bool = False
    realized_pnl: float = 0.0
    fees_paid: float = 0.0
    initial_qty: float = field(init=False)

    def __post_init__(self):
        self.initial_qty = self.qty
Enter fullscreen mode Exit fullscreen mode

Two fields do the heavy lifting. partial_done is the latch that guarantees the 3% rule fires once, not on every candle that closes above the threshold. initial_qty preserves the original size so you can report percentage-of-position metrics after the exit.

Note that entry_price never changes. After a partial exit, the remaining shares still carry the same cost basis — the average entry of the remaining position is unchanged. What changes is how much capital is at risk. If you recompute an "average entry" by blending realized and unrealized, you'll corrupt the R-multiple math later. Keep cost basis immutable; track realized PnL separately.

The Candle Loop and Check Order

The rule must be evaluated exactly once per candle, and the order of checks matters because a single candle can touch the stop, the partial level, and the target. Here's the loop I use:

def process_candle(pos: Position, candle, fee_rate: float = 0.001):
    o, h, l, c = candle.open, candle.high, candle.low, candle.close

    # 1. Gap handling: if the open is already beyond a level,
    #    fill at the open, not at the level.
    # 2. Stop check first (conservative: assume worst case within candle).
    stop_hit = l <= pos.stop_price
    target_hit = h >= pos.target_price
    partial_hit = (not pos.partial_done) and h >= pos.partial_level

    # Conservative ordering: stop before target when both touched.
    if stop_hit:
        fill = min(o, pos.stop_price) if o < pos.stop_price else pos.stop_price
        close_remainder(pos, fill, fee_rate)
        return "stopped"

    if partial_hit:
        take_partial(pos, max(o, pos.partial_level), fee_rate)
        # Re-evaluate target on the same candle with reduced size.
        if h >= pos.target_price:
            fill = max(o, pos.target_price)
            close_remainder(pos, fill, fee_rate)
            return "partial+target"
        return "partial"

    if target_hit:
        fill = max(o, pos.target_price)
        close_remainder(pos, fill, fee_rate)
        return "target"

    return "open"
Enter fullscreen mode Exit fullscreen mode

The ordering decisions, spelled out:

Stop before partial. If a candle's range covers both the stop and the 3% level, assume the stop got hit first. Without tick data you cannot know the intrabar sequence, and assuming the favorable fill first is how backtests develop a personality disorder. Conservative ordering costs you some hypothetical profit and keeps the equity curve honest.

Partial before target. Once the 3% level triggers, the target check runs against the reduced position. If one candle runs from below 3% straight through 6%, you take the partial at 3% and the remainder at 6%. That's the intended behavior: the rule is a schedule, not an either/or.

Gap opens. If the candle opens above the target, the fill is the open, not the target — you don't get to sell at a price the market skipped. The max(o, level) / min(o, level) expressions handle long positions; flip them for shorts. Gap opens beyond both the partial and the target (open at 7%) mean the partial fills at the open too, and the remainder also fills at the open. Bank it and move on.

The Partial Fill Itself

def take_partial(pos: Position, fill_price: float, fee_rate: float):
    qty = pos.qty * pos.partial_fraction
    proceeds = qty * fill_price
    fee = proceeds * fee_rate
    cost = qty * pos.entry_price

    pos.realized_pnl += proceeds - fee - cost
    pos.fees_paid += fee
    pos.qty -= qty
    pos.partial_done = True

    # Move stop to break-even, adjusted for fees already sunk.
    pos.stop_price = pos.entry_price
Enter fullscreen mode Exit fullscreen mode

Two subtleties here. First, pos.qty -= qty uses the fractional quantity, so a 50% exit on an odd lot leaves a fractional remainder — fine for crypto, and for equities you'd round down and keep the residual. Second, the break-even stop is set to entry_price, not to entry_price + fees. That's the version most people write, and it's slightly wrong; see the fee section.

Edge Cases That Bite

The "free ride" isn't free. Moving the stop to break-even means the remainder exits flat before fees. If you paid 0.1% on entry and 0.1% on exit, a break-even stop on the remainder loses roughly 0.2% of the remaining notional. Over hundreds of trades this shows up. The fix is to set the stop at the fee-adjusted break-even:

def breakeven_price(entry: float, entry_fee_rate: float,
                    exit_fee_rate: float) -> float:
    # Price at which net PnL on the remainder is zero.
    return entry * (1 + entry_fee_rate) / (1 - exit_fee_rate)
Enter fullscreen mode Exit fullscreen mode

With 0.1% each way, that's entry × 1.001 / 0.999 ≈ entry × 1.002. Two basis points above entry — small, but it converts "free ride" from a slogan into an arithmetic fact. Whether the fee-adjusted stop or the plain break-even stop performs better is an empirical question; test both.

Partial level below entry after slippage. If your fill model includes slippage, the realized PnL on the partial can be negative even though the trigger price was 3% above entry. Guard the expectancy math against this by using actual fill prices everywhere, never the trigger levels.

Restart safety. If the trader persists state between runs, partial_done must be serialized. Reconstructing a position from "current qty and entry" alone will re-fire the partial after a restart. Persist the full dataclass.

One partial per position. The latch is not an optimization; it's the semantics. If you want multiple scale-out levels (3%, 5%, 7%), replace the boolean with a list of pending levels and pop them as they trigger. The bookkeeping pattern is identical.

How Partial TP Changes Expectancy

The standard expectancy formula for a single-target system is:

E = p_win * avg_win - p_loss * avg_loss
Enter fullscreen mode Exit fullscreen mode

Partial exits break avg_win into two components and change the outcome distribution. With half out at 3% and half at 6% (ignoring fees and the break-even stop for a moment), a full winner returns 0.5 * 3% + 0.5 * 6% = 4.5% of position notional. A trade that reaches 3% but stalls returns 0.5 * 3% + 0 = 1.5% instead of riding back to zero. A trade stopped at break-even after the partial returns 1.5% instead of a full loss.

The trade-off: you've capped the average winner at 4.5% instead of 6%, in exchange for converting some would-be losers into small winners and shrinking the loss tail. Whether that improves expectancy depends on the distribution of maximum favorable excursion (MFE) — specifically, how many trades reach 3% versus 6%. If most trades that hit 3% also hit 6%, the partial just costs you half a percent per winner. If a large fraction stall between 3% and 6%, the partial is where the edge lives.

You can't reason your way to the answer; you measure it. Log MFE per trade and build the histogram:

import numpy as np

def mfe_histogram(trades, bins=(0, 1, 2, 3, 4, 5, 6, 8, 10)):
    mfe = np.array([t.max_favorable_pct for t in trades])
    hist, edges = np.histogram(mfe, bins=bins)
    for lo, hi, n in zip(edges[:-1], edges[1:], hist):
        print(f"{lo:>4.0f}%-{hi:<4.0f}%  {n:>5d}  {'#' * int(50 * n / max(hist))}")
Enter fullscreen mode Exit fullscreen mode

If the 3–6% bucket is fat, the partial earns its keep. If it's nearly empty, a single wider target is simpler and better.

A Cleaner Expectancy Formula

Once partials exist, per-trade expectancy is best computed from realized cash flows rather than win/loss averages:

def expectancy(closed_trades):
    # Each trade: dict with 'pnl_pct' (net return on initial notional)
    pnl = np.array([t["pnl_pct"] for t in closed_trades])
    return pnl.mean(), pnl.std() / np.sqrt(len(pnl))
Enter fullscreen mode Exit fullscreen mode

Because every trade now has a continuum of outcomes (full stop, partial + break-even, partial + target, direct target), the win/loss split loses information. Mean net return per trade on initial notional, with a standard error, is the honest summary. Report it alongside the MFE histogram and the fraction of trades where the partial fired.

What Actually Matters

The partial take-profit is a small piece of logic wrapped in a lot of state discipline. Get these right and the rest follows:

  • Latch the partial with an explicit flag and persist it.
  • Evaluate stop → partial → target, once per candle, with gap-aware fills.
  • Keep entry price immutable; track realized PnL separately.
  • Use fee-adjusted break-even if you care about the "free" part being real.
  • Judge the rule by the MFE distribution and net expectancy, not by how it feels.

More notes like this ship every week on this site.


Daily Picks

The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-31 08:47:06

Long

Pair Signal Price Take Profit Stop Loss R/R
Q 短平快做多 $0.0434 $0.0447 $0.0423 1:1.2

1 picks selected. Scanner runs every 15 minutes.

Top comments (0)