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
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"
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
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)
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
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))}")
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))
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)