SKILL.md Section 5 rewritten with PointValue-based formulas:
- Direction A: SL points → SL price
- Direction B: Risk% + lots → SL price (with currency conversion)
- Direction C: SL price + risk% → lot size
- FindFXRate helper for profit_currency ≠ account_currency
- OrderCalcProfit verification pattern
Section 8 expanded with 6 SL/TP pitfalls (PointValue vs TICK_VALUE,
TickSize ≠ Point, currency conversion, NormalizeDouble rounding,
lot step quantization, STOPS_LEVEL check).
Section 9 EA skeleton now includes inline PointValue, FindFXRate,
CalcSLFromRisk, CalcLotsFromSL functions.
New files:
- LICENSE (MIT)
- README.md
- docs-dev/symbol-spec.md (symbol spec workflow)
- skills/mql5/references/symbol-spec/specs-{XAUUSD,USDJPY}.csv
- skills/mql5/scripts/verify_sl_tp_formulas.py (Python verification)
Updated: pyproject.toml, AGENTS.md, .gitignore, docs-dev/skill-design.md
401 lines
16 KiB
Python
401 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Verify SL/TP calculation formulas from MQL5 documentation.
|
||
|
||
Two symbol types:
|
||
- XAUUSD: CFD Leverage mode → Profit = (close-open) * ContractSize * Lots
|
||
- USDJPY: Forex mode → Profit = (close-open) * ContractSize * Lots
|
||
(but profit currency = JPY, so dollar-equivalent needs conversion)
|
||
|
||
Bid prices used: XAUUSD=4121.28, USDJPY=161.561
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
import csv
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
# ── Spec loader ──────────────────────────────────────────────────────
|
||
|
||
SPEC_DIR = Path(__file__).resolve().parent.parent / "references" / "symbol-spec"
|
||
|
||
|
||
@dataclass
|
||
class SymbolSpec:
|
||
name: str
|
||
digits: int
|
||
contract_size: float
|
||
calc_mode: str
|
||
tick_size: float
|
||
tick_value: float
|
||
stops_level: int
|
||
profit_currency: str
|
||
lot_min: float
|
||
lot_max: float
|
||
lot_step: float
|
||
|
||
@property
|
||
def point(self) -> float:
|
||
"""SYMBOL_POINT = 10^(-digits)"""
|
||
return 10 ** (-self.digits)
|
||
|
||
@classmethod
|
||
def from_csv(cls, path: Path) -> "SymbolSpec":
|
||
rows: dict[str, str] = {}
|
||
with open(path, newline="") as f:
|
||
for row in csv.reader(f):
|
||
if len(row) >= 2:
|
||
rows[row[0].strip()] = row[1].strip()
|
||
return cls(
|
||
name=path.stem.replace("specs-", ""),
|
||
digits=int(rows["Digits"]),
|
||
contract_size=float(rows["Contract size"]),
|
||
calc_mode=rows["Calculation"],
|
||
tick_size=float(rows["Tick size"]),
|
||
tick_value=float(rows["Tick value"]),
|
||
stops_level=int(rows["Stops level"]),
|
||
profit_currency=rows["Profit currency"],
|
||
lot_min=float(rows["Minimal volume"]),
|
||
lot_max=float(rows["Maximal volume"]),
|
||
lot_step=float(rows["Volume step"]),
|
||
)
|
||
|
||
|
||
# ── Core formulas ────────────────────────────────────────────────────
|
||
#
|
||
# Key distinction:
|
||
# PointValue = value of 1 POINT move for 1 lot (in profit currency)
|
||
# loss = number_of_points * PointValue * Lots
|
||
#
|
||
# Therefore:
|
||
# number_of_points = loss / (PointValue * Lots)
|
||
# sl_distance_price = number_of_points * point
|
||
#
|
||
# When profit_currency != account_currency, we must convert:
|
||
# max_loss_profcy = balance * risk_pct / 100 * fx_rate_to_profcy
|
||
#
|
||
|
||
def point_value(spec: SymbolSpec) -> float:
|
||
"""
|
||
MPM::PointValue: value of 1 POINT move for 1 lot, in profit currency.
|
||
|
||
Forex/CFD: point * ContractSize (since Profit = delta * ContractSize * Lots)
|
||
Futures: point * TickValue / TickSize
|
||
"""
|
||
mode = spec.calc_mode.lower()
|
||
if "forex" in mode or "cfd" in mode or "stock" in mode:
|
||
return spec.point * spec.contract_size
|
||
elif "future" in mode:
|
||
return spec.point * spec.tick_value / spec.tick_size
|
||
raise ValueError(f"Unsupported calc mode: {spec.calc_mode}")
|
||
|
||
|
||
def risk_amount(
|
||
balance_usd: float,
|
||
risk_pct: float,
|
||
spec: SymbolSpec,
|
||
fx_rate: float,
|
||
) -> float:
|
||
"""
|
||
Convert risk from account currency (USD) to profit currency.
|
||
|
||
fx_rate: how many units of profit_currency per 1 USD
|
||
e.g. USDJPY=161.561 → fx_rate=161.561
|
||
XAUUSD (USD=USD) → fx_rate=1.0
|
||
"""
|
||
return balance_usd * risk_pct / 100.0 * fx_rate
|
||
|
||
|
||
def calc_sl_from_risk(
|
||
spec: SymbolSpec,
|
||
max_loss_profcy: float, # risk budget in profit currency
|
||
lots: float,
|
||
open_price: float,
|
||
direction: str, # "BUY" or "SELL"
|
||
) -> float:
|
||
"""
|
||
Given risk budget (in profit currency) and lot size, compute SL price.
|
||
|
||
points = max_loss / (PointValue * Lots) (number of points)
|
||
sl_price = open_price ± points * point
|
||
|
||
BUY: SL = open - points * point
|
||
SELL: SL = open + points * point
|
||
"""
|
||
pv = point_value(spec)
|
||
points = max_loss_profcy / (pv * lots)
|
||
sl_distance_price = points * spec.point
|
||
|
||
if direction == "BUY":
|
||
sl = open_price - sl_distance_price
|
||
else:
|
||
sl = open_price + sl_distance_price
|
||
return round(sl, spec.digits)
|
||
|
||
|
||
def calc_lots_from_sl(
|
||
spec: SymbolSpec,
|
||
max_loss_profcy: float, # risk budget in profit currency
|
||
open_price: float,
|
||
sl_price: float,
|
||
) -> float:
|
||
"""
|
||
Given a fixed SL price, compute lot size so that loss == max_loss_profcy.
|
||
|
||
sl_distance_price = abs(open - sl)
|
||
points = sl_distance_price / point
|
||
Lots = max_loss / (PointValue * points)
|
||
|
||
Then normalize to lot_step, clamp to [lot_min, lot_max].
|
||
"""
|
||
sl_distance_price = abs(open_price - sl_price)
|
||
if sl_distance_price == 0:
|
||
return 0.0
|
||
pv = point_value(spec)
|
||
points = sl_distance_price / spec.point
|
||
raw_lots = max_loss_profcy / (pv * points)
|
||
|
||
# Normalize to lot_step
|
||
normed = int(raw_lots / spec.lot_step) * spec.lot_step
|
||
normed = max(normed, spec.lot_min)
|
||
normed = min(normed, spec.lot_max)
|
||
return round(normed, 8)
|
||
|
||
|
||
def calc_profit(
|
||
spec: SymbolSpec, lots: float, open_price: float, close_price: float
|
||
) -> float:
|
||
"""
|
||
Profit in profit currency (from OrderCalcProfit formulas).
|
||
"""
|
||
mode = spec.calc_mode.lower()
|
||
if "forex" in mode or "cfd" in mode or "stock" in mode:
|
||
return (close_price - open_price) * spec.contract_size * lots
|
||
elif "future" in mode:
|
||
return (close_price - open_price) * spec.tick_value / spec.tick_size * lots
|
||
raise ValueError(f"Unsupported: {spec.calc_mode}")
|
||
|
||
|
||
# ── Display helpers ──────────────────────────────────────────────────
|
||
|
||
SEP = "─" * 72
|
||
PCY = " " # profit currency suffix
|
||
|
||
|
||
def fmt(v: float, d: int) -> str:
|
||
return f"{v:,.{d}f}"
|
||
|
||
|
||
def run_tests(
|
||
spec: SymbolSpec, bid: float, account_balance: float, fx_rate: float
|
||
):
|
||
pc = spec.profit_currency # e.g. "JPY" or "USD"
|
||
|
||
print(f"\n{SEP}")
|
||
print(f" Symbol: {spec.name} | Calc Mode: {spec.calc_mode}")
|
||
print(f" Digits={spec.digits} ContractSize={spec.contract_size}")
|
||
print(f" Point={spec.point} TickSize={spec.tick_size} TickValue={spec.tick_value}")
|
||
print(f" Profit currency: {pc} | FX rate: {fx_rate} {pc}/USD")
|
||
print(SEP)
|
||
|
||
pv = point_value(spec)
|
||
print(f" PointValue = {pv} ({pc} per 1-point move, 1 lot)")
|
||
print()
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Test 1: SL → Profit round-trip (verify formula correctness)
|
||
# ─────────────────────────────────────────────────────────────────
|
||
print(" TEST 1: SL→Profit round-trip (fixed lots=0.10)")
|
||
lots = 0.10
|
||
|
||
for sl_distance_pts in [100, 500, 1000, 2000]:
|
||
sl_dist_price = sl_distance_pts * spec.point
|
||
sl_buy = round(bid - sl_dist_price, spec.digits)
|
||
loss_buy = calc_profit(spec, lots, bid, sl_buy)
|
||
|
||
print(f" SL距离={sl_distance_pts:>5} pts "
|
||
f"→ 价格距离={sl_dist_price} "
|
||
f"BUY SL={fmt(sl_buy, spec.digits)} "
|
||
f"亏损={fmt(loss_buy, spec.digits)} {pc}")
|
||
|
||
# Also verify with Ask = Bid + spread
|
||
spread_pts = 12 if spec.name == "XAUUSD" else 3
|
||
ask = round(bid + spread_pts * spec.point, spec.digits)
|
||
print(f" (Ask={fmt(ask, spec.digits)}, spread={spread_pts} pts)")
|
||
print()
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Test 2: Risk% → SL price (forward direction)
|
||
# ─────────────────────────────────────────────────────────────────
|
||
print(f" TEST 2: Risk%→SL (Balance={fmt(account_balance, 2)} USD, Lots=0.10)")
|
||
for risk_pct in [0.5, 1.0, 2.0, 5.0]:
|
||
ml = risk_amount(account_balance, risk_pct, spec, fx_rate)
|
||
for direction in ["BUY", "SELL"]:
|
||
price = bid if direction == "BUY" else ask
|
||
sl = calc_sl_from_risk(spec, ml, lots, price, direction)
|
||
|
||
# Verify: compute actual loss at this SL
|
||
actual_loss = calc_profit(spec, lots, price, sl)
|
||
|
||
print(f" Risk={risk_pct}% {direction:4s} "
|
||
f"SL={fmt(sl, spec.digits)} "
|
||
f"目标亏损={fmt(ml, 2)} {pc} "
|
||
f"实际亏损={fmt(actual_loss, 2)} {pc} "
|
||
f"差={fmt(abs(actual_loss) - ml, 6)}")
|
||
print()
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Test 3: Fixed SL price → Lot size (reverse direction)
|
||
# ─────────────────────────────────────────────────────────────────
|
||
risk_pct = 1.0
|
||
ml = risk_amount(account_balance, risk_pct, spec, fx_rate)
|
||
print(f" TEST 3: Fixed SL→Lots (Balance={fmt(account_balance, 2)} USD, Risk={risk_pct}%)")
|
||
print(f" risk budget = {fmt(ml, 2)} {pc}")
|
||
for sl_distance_pts in [100, 500, 1000, 2000]:
|
||
sl_dist_price = sl_distance_pts * spec.point
|
||
sl_buy = round(bid - sl_dist_price, spec.digits)
|
||
lots_calc = calc_lots_from_sl(spec, ml, bid, sl_buy)
|
||
|
||
if lots_calc > 0:
|
||
actual_loss = calc_profit(spec, lots_calc, bid, sl_buy)
|
||
else:
|
||
actual_loss = 0.0
|
||
|
||
print(f" SL距离={sl_distance_pts:>5} pts "
|
||
f"SL={fmt(sl_buy, spec.digits)} "
|
||
f"计算手数={lots_calc:.4f} "
|
||
f"实际亏损={fmt(actual_loss, 2)} {pc} "
|
||
f"差={fmt(abs(actual_loss) - ml, 6)}")
|
||
print()
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Test 4: Cross-verify — forward vs reverse should match
|
||
# ─────────────────────────────────────────────────────────────────
|
||
print(" TEST 4: Cross-verify forward↔reverse")
|
||
test_cases = [
|
||
(0.5, 500),
|
||
(1.0, 1000),
|
||
(2.0, 1500),
|
||
]
|
||
for risk_pct, sl_pts in test_cases:
|
||
ml = risk_amount(account_balance, risk_pct, spec, fx_rate)
|
||
sl_dist_price = sl_pts * spec.point
|
||
sl_buy = round(bid - sl_dist_price, spec.digits)
|
||
|
||
# Forward: risk% → SL (with lots=0.10)
|
||
lots_fwd = 0.10
|
||
sl_fwd = calc_sl_from_risk(spec, ml, lots_fwd, bid, "BUY")
|
||
|
||
# Reverse: SL → lots
|
||
lots_rev = calc_lots_from_sl(spec, ml, bid, sl_buy)
|
||
|
||
# Forward loss check
|
||
loss_fwd = calc_profit(spec, lots_fwd, bid, sl_fwd)
|
||
|
||
# Reverse loss check
|
||
loss_rev = calc_profit(spec, lots_rev, bid, sl_buy)
|
||
|
||
print(f" Risk={risk_pct}% SL距离={sl_pts}pts budget={fmt(ml, 2)} {pc}")
|
||
print(f" Forward: SL={fmt(sl_fwd, spec.digits)} lots={lots_fwd:.2f} "
|
||
f"loss={fmt(loss_fwd, 2)} {pc} (budget={fmt(ml, 2)})")
|
||
print(f" Reverse: lots={lots_rev:.4f} "
|
||
f"loss={fmt(loss_rev, 2)} {pc} (budget={fmt(ml, 2)})")
|
||
print(f" Δloss = {fmt(abs(loss_fwd) - ml, 6)} "
|
||
f"(forward vs budget)")
|
||
print()
|
||
|
||
|
||
# ── Main ─────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
BALANCE = 10000.0 # USD demo account
|
||
|
||
specs = {
|
||
"XAUUSD": SymbolSpec.from_csv(SPEC_DIR / "specs-XAUUSD.csv"),
|
||
"USDJPY": SymbolSpec.from_csv(SPEC_DIR / "specs-USDJPY.csv"),
|
||
}
|
||
bids = {"XAUUSD": 4121.28, "USDJPY": 161.561}
|
||
|
||
# FX rates: profit_currency per 1 USD
|
||
# XAUUSD: profit=USD → rate=1.0
|
||
# USDJPY: profit=JPY → rate=USDJPY_bid
|
||
fx_rates = {"XAUUSD": 1.0, "USDJPY": bids["USDJPY"]}
|
||
|
||
print("=" * 72)
|
||
print(" MQL5 SL/TP Formula Verification")
|
||
print(f" Account Balance: {BALANCE:,.2f} USD")
|
||
print("=" * 72)
|
||
|
||
for name in ["XAUUSD", "USDJPY"]:
|
||
run_tests(specs[name], bids[name], BALANCE, fx_rates[name])
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Special: USDJPY — currency conversion walkthrough
|
||
# ─────────────────────────────────────────────────────────────────
|
||
print(SEP)
|
||
print(" USDJPY: Currency Conversion Walkthrough")
|
||
print(SEP)
|
||
jpy_spec = specs["USDJPY"]
|
||
jpy_rate = bids["USDJPY"]
|
||
lots = 0.10
|
||
risk_pct = 1.0
|
||
|
||
# Step 1-2: risk budget in profit currency
|
||
target_usd = BALANCE * risk_pct / 100.0 # = 100.00 USD
|
||
target_jpy = target_usd * jpy_rate # = 16,156.10 JPY
|
||
|
||
# Step 3: formula → SL
|
||
ml_jpy = risk_amount(BALANCE, risk_pct, jpy_spec, jpy_rate)
|
||
sl_fwd = calc_sl_from_risk(jpy_spec, ml_jpy, lots, bids["USDJPY"], "BUY")
|
||
|
||
# Step 4-5: verify
|
||
loss_jpy = calc_profit(jpy_spec, lots, bids["USDJPY"], sl_fwd)
|
||
loss_usd = loss_jpy / jpy_rate
|
||
|
||
print(f" Risk={risk_pct}%, Lots={lots}, Balance={fmt(BALANCE, 2)} USD")
|
||
print()
|
||
print(f" Step 1: risk budget (USD) = {fmt(BALANCE, 2)} × {risk_pct}% = {fmt(target_usd, 2)} USD")
|
||
print(f" Step 2: convert to JPY = {fmt(target_usd, 2)} × {jpy_rate} = {fmt(target_jpy, 2)} JPY")
|
||
print(f" Step 3: points = budget / (PointValue × Lots)")
|
||
print(f" = {fmt(target_jpy, 2)} / ({point_value(jpy_spec):.1f} × {lots}) = {target_jpy / (point_value(jpy_spec) * lots):.1f} pts")
|
||
print(f" SL距离 = {target_jpy / (point_value(jpy_spec) * lots):.1f} × {jpy_spec.point} = "
|
||
f"{target_jpy / (point_value(jpy_spec) * lots) * jpy_spec.point:.4f} price")
|
||
print(f" SL = {bids['USDJPY']} - {target_jpy / (point_value(jpy_spec) * lots) * jpy_spec.point:.4f} = "
|
||
f"{fmt(sl_fwd, jpy_spec.digits)}")
|
||
print()
|
||
print(f" Step 4: actual loss = {fmt(loss_jpy, 2)} JPY")
|
||
print(f" Step 5: loss in USD = {fmt(loss_jpy, 2)} / {jpy_rate} = {fmt(loss_usd, 2)} USD")
|
||
print()
|
||
print(f" Result: target {fmt(target_usd, 2)} USD ≈ actual {fmt(loss_usd, 2)} USD "
|
||
f"(差={fmt(abs(loss_usd) - target_usd, 4)} USD, "
|
||
f"来自 NormalizeDouble 四舍五入)")
|
||
print()
|
||
|
||
# ─────────────────────────────────────────────────────────────────
|
||
# Special: XAUUSD lots sensitivity for 1% risk
|
||
# ─────────────────────────────────────────────────────────────────
|
||
print(SEP)
|
||
print(" XAUUSD: Lots vs SL distance for 1% risk ($100 target loss)")
|
||
print(SEP)
|
||
xau = specs["XAUUSD"]
|
||
ml_usd = risk_amount(BALANCE, 1.0, xau, 1.0)
|
||
for lots in [0.01, 0.05, 0.10, 0.50, 1.00, 2.00]:
|
||
pv = point_value(xau)
|
||
points = ml_usd / (pv * lots)
|
||
sl_dist_price = points * xau.point
|
||
sl_dist_pts = int(points)
|
||
sl = round(bids["XAUUSD"] - sl_dist_price, xau.digits)
|
||
|
||
# Verify
|
||
actual_loss = calc_profit(xau, lots, bids["XAUUSD"], sl)
|
||
|
||
print(f" Lots={lots:>5.2f} "
|
||
f"SL距离={sl_dist_pts:>6} pts ({sl_dist_price:.2f} price) "
|
||
f"SL={fmt(sl, xau.digits)} "
|
||
f"亏损={fmt(actual_loss, 2)} USD "
|
||
f"差={fmt(abs(actual_loss) - ml_usd, 6)}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|