Why the Monthly Review Is the Only Review That Matters
A paper-trading log accumulates trades faster than anyone reviews them. By the end of a month you have a trades.json with a few hundred entries and a vague feeling that "it's working." That feeling is not a statistic. The monthly review exists to replace it with a table of numbers that can be wrong in specific, correctable ways.
This post covers the exact formulas for the core metrics, why they disagree with each other, how fees and funding quietly rewrite the story, and a Python function that turns a raw trade log into a monthly table you can push to WeChat.
The Trade Record
Everything downstream depends on the shape of the input. A trade needs, at minimum:
{
"id": "t_20240312_004",
"ts_open": "2024-03-12T09:14:00Z",
"ts_close": "2024-03-12T15:41:00Z",
"strategy": "mom_breakout",
"signal": "range_break",
"side": "long",
"qty": 0.35,
"entry": 41250.0,
"exit": 41810.0,
"fee": 4.12, # taker/maker fees, both legs, in quote currency
"funding": -1.30, # net funding paid (negative) or received (positive)
"pnl_gross": 196.0, # (exit - entry) * qty * direction
"pnl_net": 192.58 # pnl_gross - fee + funding
}
Two rules matter here. First, pnl_net must be computed at write time and stored, not recomputed later from entry/exit — exchange fee tiers and funding schedules change, and you want the number that was true when the trade closed. Second, funding is signed: paid is negative, received is positive. Mixing the sign convention is the single most common source of a "profitable" paper account that isn't.
The Core Formulas
Given a list of closed trades, define winners as trades with pnl_net > 0 and losers as pnl_net < 0. Trades with exactly zero net PnL are excluded from win-rate denominators — they're neither wins nor losses, and counting them as losses flatters nothing and distorts everything.
-
Trade count:
N = len(trades) -
Win rate:
W / (W + L)whereWandLare counts of winners and losers. -
Average win:
mean(pnl_net for winners) -
Average loss:
mean(pnl_net for losers)— this is negative; report it as a negative number, not an absolute value, to avoid sign confusion downstream. -
Expectancy (EV per trade):
mean(pnl_net for all trades). Equivalentlywin_rate * avg_win + loss_rate * avg_loss. -
Profit factor:
sum(pnl_net for winners) / abs(sum(pnl_net for losers)) -
Max drawdown: computed on the cumulative net PnL curve,
max(peak - equity)over time. -
Longest losing streak: the longest consecutive run of
pnl_net < 0in close-time order.
import statistics as st
def core_metrics(trades):
if not trades:
return {}
pnls = [t["pnl_net"] for t in trades]
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_win = sum(wins)
gross_loss = abs(sum(losses))
equity, peak, max_dd = 0.0, 0.0, 0.0
streak = longest = 0
for p in pnls:
equity += p
peak = max(peak, equity)
max_dd = max(max_dd, peak - equity)
streak = streak + 1 if p < 0 else 0
longest = max(longest, streak)
return {
"n": len(pnls),
"win_rate": len(wins) / (len(wins) + len(losses)) if wins or losses else 0.0,
"avg_win": st.mean(wins) if wins else 0.0,
"avg_loss": st.mean(losses) if losses else 0.0,
"expectancy": st.mean(pnls),
"profit_factor": gross_win / gross_loss if gross_loss else float("inf"),
"max_drawdown": max_dd,
"longest_loss_streak": longest,
}
Note that drawdown here is in quote currency, not percent. Percent drawdown requires an account equity baseline, which a trade log alone doesn't give you. If you track a starting balance, divide by the running peak equity instead — but be explicit about which one you're reporting, because a 400-unit drawdown means very different things on a 2,000 and a 20,000 account.
Why Win Rate, Expectancy and Profit Factor Disagree
These three numbers answer different questions, and a system can look good on one while being bad on another.
High win rate, negative expectancy. A strategy that wins 80% of the time but loses 5x as much on the losers has positive win rate and negative EV. Expectancy catches this; win rate hides it.
Profit factor and expectancy can point in opposite directions across sample sizes. Expectancy is per trade, so it scales with trade count in aggregate but stays comparable between strategies with different frequencies. Profit factor is a ratio of sums, so a low-frequency strategy with one huge winner can post a profit factor of 3.0 on ten trades while its per-trade expectancy is mediocre and its variance is enormous. When comparing strategies, read expectancy for "is this worth doing per trade" and profit factor for "how much cushion do the winners give the losers." A profit factor below 1.0 with positive expectancy is possible only when fees dominate — which is a signal that something in the cost model is wrong.
The honest combination is expectancy > 0, profit factor comfortably above 1.0 (1.2–1.5 is a reasonable working range for intraday), and a max drawdown you can survive. Any single one in isolation is a vanity metric.
Fees and Funding: Mark Them or Lie to Yourself
The most common paper-trading failure is computing PnL from entry and exit prices only. On a strategy with a 0.3% average edge per trade and 0.08% round-trip taker fees, fees eat over a quarter of the edge. Add funding on a position held overnight and a marginal strategy flips negative.
Two practices keep this honest:
-
Store gross and net separately. Then run the review on net. Keep gross around only to measure cost drag:
total_fees / gross_wintells you what fraction of your winners you're handing to the exchange. - Attribute funding per trade. Funding is charged at discrete timestamps; if a trade spans several funding windows, sum the actual charges, don't estimate from average rates.
def cost_drag(trades):
gross = sum(t["pnl_gross"] for t in trades)
fees = sum(t["fee"] for t in trades)
funding = sum(-t["funding"] for t in trades) # cost is positive
return {
"gross_pnl": gross,
"fees": fees,
"funding_cost": funding,
"net_pnl": gross - fees - funding,
"fee_share_of_gross": fees / gross if gross > 0 else None,
}
If fee_share_of_gross is above roughly 0.3 for an intraday system, the review should say so explicitly. That number is usually more actionable than the win rate.
Breaking Down per Strategy and per Signal
Aggregate numbers hide which component is carrying the account and which one is bleeding. The same metrics computed per strategy and per signal are the actual output of the review.
from collections import defaultdict
def group_metrics(trades, key):
buckets = defaultdict(list)
for t in trades:
buckets[t[key]].append(t)
return {k: core_metrics(v) for k, v in buckets.items()}
Read the per-strategy table with sample size in view. A strategy with 12 trades and a 1.9 profit factor is noise; a strategy with 180 trades and a 1.1 profit factor is a real, thin edge. Require a minimum count (30 is a common floor) before drawing conclusions, and flag anything below it as "insufficient sample" in the output rather than ranking it.
The Monthly Table
The review itself is a filter by close timestamp, then the metrics, then a formatted table.
from datetime import datetime, timezone
def monthly_review(trades, year, month):
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = datetime(year + (month == 12), month % 12 + 1, 1, tzinfo=timezone.utc)
window = [
t for t in trades
if start <= datetime.fromisoformat(t["ts_close"].replace("Z", "+00:00")) < end
]
window.sort(key=lambda t: t["ts_close"])
return {
"period": f"{year}-{month:02d}",
"overall": core_metrics(window),
"by_strategy": group_metrics(window, "strategy"),
"by_signal": group_metrics(window, "signal"),
"costs": cost_drag(window),
}
Pushing to WeChat
WeChat Work (企业微信) group bots accept a JSON POST with a markdown content field. Format the table as markdown text and send it.
import json, urllib.request
def render_markdown(review):
o = review["overall"]
c = review["costs"]
lines = [
f"**Paper Review {review['period']}**",
f"Trades: {o['n']} | Win rate: {o['win_rate']:.1%}",
f"Avg win: {o['avg_win']:.2f} | Avg loss: {o['avg_loss']:.2f}",
f"Expectancy: {o['expectancy']:.2f}/trade | PF: {o['profit_factor']:.2f}",
f"Max DD: {o['max_drawdown']:.2f} | Longest loss streak: {o['longest_loss_streak']}",
f"Fees: {c['fees']:.2f} | Funding: {c['funding_cost']:.2f}",
"",
"**By strategy**",
]
for name, m in review["by_strategy"].items():
flag = "" if m["n"] >= 30 else " (low n)"
lines.append(
f"- {name}: n={m['n']}{flag} EV={m['expectancy']:.2f} "
f"PF={m['profit_factor']:.2f} WR={m['win_rate']:.1%}"
)
return "\n".join(lines)
def push_wechat(webhook_url, content):
payload = json.dumps({"msgtype": "markdown", "markdown": {"content": content}}).encode()
req = urllib.request.Request(
webhook_url, data=payload, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.read().decode()
Wire it to a monthly cron job, load trades.json, run monthly_review, render, push. The whole pipeline is under a hundred lines and runs in a second.
What to Actually Do With the Table
The review is not a scoreboard; it's a triage list. Sort by expectancy per strategy. Anything with a negative expectancy and a sample of 30+ trades is a candidate for shutdown or a parameter change. Anything with a positive expectancy but a fee share above 0.3 goes to the execution side — maker orders, wider targets — before the signal side. Anything below 30 trades gets more data, not more tuning. The longest losing streak tells you what the equity curve will feel like; if it's longer than your patience, the system is right and the sizing is wrong.
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-11-03 08:46:50
Short
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| ESPORTS | 庄家做空 | $0.0147 | $0.0139 | $0.0153 | 1:1.6 |
1 picks selected. Scanner runs every 15 minutes.
Top comments (0)