Skip to content

Commit 22a3da5

Browse files
HenryHenry
authored andcommitted
fix grid accounting and secure dependencies
1 parent e64e1c2 commit 22a3da5

4 files changed

Lines changed: 257 additions & 14 deletions

File tree

backend_api_python/app/services/strategy_v2/runtime.py

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,28 @@ def _position_key(symbol: object, position_side: object = "") -> str:
6565
return f"{base}::{side}" if side else base
6666

6767

68+
def _grid_order_identity(client_order_id: object) -> tuple[int, str, str, int] | None:
69+
"""Return the stable grid-cell identity encoded by the V2 robot template."""
70+
parts = str(client_order_id or "").strip().split("-")
71+
if len(parts) != 5 or parts[0] != "grid":
72+
return None
73+
try:
74+
cell_index = int(parts[1])
75+
cycle = int(parts[4])
76+
except (TypeError, ValueError):
77+
return None
78+
position_side = _normalize_position_side(parts[2])
79+
phase = str(parts[3] or "").strip().lower()
80+
if (
81+
cell_index < 0
82+
or cycle < 1
83+
or not position_side
84+
or phase not in {"entry", "exit"}
85+
):
86+
return None
87+
return cell_index, position_side, phase, cycle
88+
89+
6890
def _snapshot_state_value(value: Any) -> Any:
6991
if isinstance(value, pd.Timestamp):
7092
return {
@@ -597,6 +619,7 @@ def __init__(
597619
self.executions: list[dict[str, Any]] = []
598620
self.closed_trades: list[dict[str, Any]] = []
599621
self._entries: dict[str, dict[str, Any]] = {}
622+
self._grid_entries: dict[str, dict[str, Any]] = {}
600623
self._protections: dict[str, ProtectionState] = {}
601624
self.protection_events: list[dict[str, Any]] = []
602625
self.order_ledger: list[dict[str, Any]] = []
@@ -814,6 +837,7 @@ def execute(
814837
"commission": fee,
815838
"balance": self.portfolio.total_value,
816839
"reason": order.reason,
840+
"client_order_id": str(order.client_order_id or ""),
817841
"signal_time": _backtest_time_iso(order.signal_time if order.signal_time is not None else timestamp),
818842
"fill_reference": fill_reference,
819843
"reference_price": open_price,
@@ -1279,14 +1303,19 @@ def _record_closed_trade(
12791303
entry_quantity = max(float(entry.get("quantity") or 0.0), closing_quantity)
12801304
entry_fee = float(entry.get("commission") or 0.0) * closing_quantity / entry_quantity
12811305
direction = 1.0 if old_amount > 0 else -1.0
1282-
gross_profit = (float(execution["price"]) - float(entry.get("price") or old_cost)) * closing_quantity * direction
1306+
gross_profit = (
1307+
float(execution["price"]) - float(entry.get("price") or old_cost)
1308+
) * closing_quantity * direction
12831309
profit = gross_profit - entry_fee - close_fee
1284-
self.closed_trades.append({
1310+
account_entry_price = float(entry.get("price") or old_cost)
1311+
account_entry_time = str(entry.get("time") or execution["time"])
1312+
grid_match = self._consume_grid_entry(execution, closing_quantity, close_fee)
1313+
trade = {
12851314
"symbol": symbol,
12861315
"side": str(entry.get("side") or ("long" if old_amount > 0 else "short")),
1287-
"entry_time": str(entry.get("time") or execution["time"]),
1316+
"entry_time": account_entry_time,
12881317
"exit_time": str(execution["time"]),
1289-
"entry_price": float(entry.get("price") or old_cost),
1318+
"entry_price": account_entry_price,
12901319
"exit_price": float(execution["price"]),
12911320
"quantity": closing_quantity,
12921321
"amount": closing_quantity,
@@ -1297,7 +1326,27 @@ def _record_closed_trade(
12971326
"commission": entry_fee + close_fee,
12981327
"balance": float(execution.get("balance") or 0.0),
12991328
"close_reason": str(execution.get("reason") or "strategy"),
1300-
})
1329+
"profit_basis": "account_average",
1330+
}
1331+
if grid_match is not None:
1332+
trade.update({
1333+
"entry_time": grid_match["entry_time"],
1334+
"entry_price": grid_match["entry_price"],
1335+
"gross_profit": grid_match["gross_profit"],
1336+
"entry_commission": grid_match["entry_commission"],
1337+
"commission": grid_match["commission"],
1338+
"profit": grid_match["profit"],
1339+
"matched_entry_price": grid_match["entry_price"],
1340+
"grid_matched_profit": grid_match["profit"],
1341+
"grid_cell_index": grid_match["cell_index"],
1342+
"grid_cycle": grid_match["cycle"],
1343+
"profit_basis": "grid_cell",
1344+
"account_entry_time": account_entry_time,
1345+
"account_avg_entry_price": account_entry_price,
1346+
"account_gross_profit": gross_profit,
1347+
"account_realized_profit": profit,
1348+
})
1349+
self.closed_trades.append(trade)
13011350
remaining = max(0.0, entry_quantity - closing_quantity)
13021351
if remaining > 1e-12 and old_amount * target_amount >= 0:
13031352
entry["quantity"] = remaining
@@ -1326,6 +1375,90 @@ def _record_closed_trade(
13261375
"commission": open_fee,
13271376
"side": opening_side,
13281377
}
1378+
self._record_grid_entry(execution, opening_quantity, open_fee)
1379+
1380+
@staticmethod
1381+
def _grid_entry_key(
1382+
position_key: str,
1383+
identity: tuple[int, str, str, int],
1384+
) -> str:
1385+
cell_index, position_side, _, cycle = identity
1386+
return f"{position_key}|{cell_index}|{position_side}|{cycle}"
1387+
1388+
def _record_grid_entry(
1389+
self,
1390+
execution: Mapping[str, Any],
1391+
quantity: float,
1392+
commission: float,
1393+
) -> None:
1394+
identity = _grid_order_identity(execution.get("client_order_id"))
1395+
if identity is None or identity[2] != "entry" or quantity <= 1e-12:
1396+
return
1397+
position_key = str(execution.get("position_key") or execution.get("symbol") or "")
1398+
key = self._grid_entry_key(position_key, identity)
1399+
current = self._grid_entries.get(key)
1400+
if current is None:
1401+
self._grid_entries[key] = {
1402+
"cell_index": identity[0],
1403+
"position_side": identity[1],
1404+
"cycle": identity[3],
1405+
"entry_time": str(execution.get("time") or ""),
1406+
"entry_price": float(execution.get("price") or 0.0),
1407+
"quantity": float(quantity),
1408+
"commission": float(commission),
1409+
}
1410+
return
1411+
previous_quantity = float(current.get("quantity") or 0.0)
1412+
combined_quantity = previous_quantity + float(quantity)
1413+
if combined_quantity <= 1e-12:
1414+
return
1415+
current["entry_price"] = (
1416+
float(current.get("entry_price") or 0.0) * previous_quantity
1417+
+ float(execution.get("price") or 0.0) * float(quantity)
1418+
) / combined_quantity
1419+
current["quantity"] = combined_quantity
1420+
current["commission"] = float(current.get("commission") or 0.0) + float(commission)
1421+
1422+
def _consume_grid_entry(
1423+
self,
1424+
execution: Mapping[str, Any],
1425+
closing_quantity: float,
1426+
close_commission: float,
1427+
) -> dict[str, Any] | None:
1428+
identity = _grid_order_identity(execution.get("client_order_id"))
1429+
if identity is None or identity[2] != "exit" or closing_quantity <= 1e-12:
1430+
return None
1431+
position_key = str(execution.get("position_key") or execution.get("symbol") or "")
1432+
key = self._grid_entry_key(position_key, identity)
1433+
entry = self._grid_entries.get(key)
1434+
available = float((entry or {}).get("quantity") or 0.0)
1435+
if entry is None or available + 1e-10 < closing_quantity:
1436+
return None
1437+
1438+
entry_commission_total = float(entry.get("commission") or 0.0)
1439+
entry_commission = entry_commission_total * closing_quantity / max(available, 1e-12)
1440+
remaining = max(0.0, available - closing_quantity)
1441+
if remaining <= 1e-12:
1442+
self._grid_entries.pop(key, None)
1443+
else:
1444+
entry["quantity"] = remaining
1445+
entry["commission"] = max(0.0, entry_commission_total - entry_commission)
1446+
1447+
entry_price = float(entry.get("entry_price") or 0.0)
1448+
exit_price = float(execution.get("price") or 0.0)
1449+
direction = 1.0 if identity[1] == "long" else -1.0
1450+
gross_profit = (exit_price - entry_price) * closing_quantity * direction
1451+
commission = entry_commission + float(close_commission)
1452+
return {
1453+
"cell_index": identity[0],
1454+
"cycle": identity[3],
1455+
"entry_time": str(entry.get("entry_time") or execution.get("time") or ""),
1456+
"entry_price": entry_price,
1457+
"entry_commission": entry_commission,
1458+
"commission": commission,
1459+
"gross_profit": gross_profit,
1460+
"profit": gross_profit - commission,
1461+
}
13291462

13301463

13311464
class StrategyV2BacktestRunner:
@@ -1591,6 +1724,19 @@ def _result(self) -> dict[str, Any]:
15911724
closed_trades = list(self.broker.closed_trades)
15921725
executions = list(self.broker.executions)
15931726
profits = [float(item.get("profit") or 0.0) for item in closed_trades]
1727+
account_realized_profits = [
1728+
float(
1729+
item.get("account_realized_profit")
1730+
if item.get("account_realized_profit") is not None
1731+
else item.get("profit") or 0.0
1732+
)
1733+
for item in closed_trades
1734+
]
1735+
grid_matched_profits = [
1736+
float(item.get("grid_matched_profit") or 0.0)
1737+
for item in closed_trades
1738+
if item.get("profit_basis") == "grid_cell"
1739+
]
15941740
wins = [value for value in profits if value > 0]
15951741
losses = [value for value in profits if value < 0]
15961742
returns = pd.Series(values, dtype="float64").pct_change().dropna() if values else pd.Series(dtype="float64")
@@ -1677,6 +1823,14 @@ def _result(self) -> dict[str, Any]:
16771823
"worstTrade": min(profits) if profits else 0.0,
16781824
"avgTrade": average_profit,
16791825
"averageProfit": average_profit,
1826+
"accountRealizedProfit": sum(account_realized_profits),
1827+
"gridMatchedProfit": sum(grid_matched_profits),
1828+
"gridMatchedTradeCount": len(grid_matched_profits),
1829+
"tradeProfitBasis": (
1830+
"grid_cell_when_available"
1831+
if grid_matched_profits
1832+
else "account_average"
1833+
),
16801834
"totalProfit": final - initial,
16811835
"sharpeRatio": sharpe_ratio,
16821836
"annualizedReturn": annualized_return,
@@ -1713,7 +1867,14 @@ def _attribution(self, initial: float) -> dict[str, Any]:
17131867
commission_by_symbol[symbol] = commission_by_symbol.get(symbol, 0.0) + float(execution.get("commission") or 0.0)
17141868
for trade in self.broker.closed_trades:
17151869
symbol = str(trade.get("symbol") or "")
1716-
realized_by_symbol[symbol] = realized_by_symbol.get(symbol, 0.0) + float(trade.get("profit") or 0.0)
1870+
account_profit = (
1871+
trade.get("account_realized_profit")
1872+
if trade.get("account_realized_profit") is not None
1873+
else trade.get("profit")
1874+
)
1875+
realized_by_symbol[symbol] = (
1876+
realized_by_symbol.get(symbol, 0.0) + float(account_profit or 0.0)
1877+
)
17171878
rows = []
17181879
for symbol in sorted(set(commission_by_symbol) | set(realized_by_symbol) | set(self.broker.portfolio.positions)):
17191880
position = self.broker.portfolio.positions.get(symbol)

backend_api_python/requirements.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Generated from requirements.txt for the verified Python 3.12 Linux production image.
22
# Regenerate and validate this file whenever direct production requirements change.
33
aiohappyeyeballs==2.7.1
4-
aiohttp==3.14.1
4+
aiohttp==3.14.3
55
aiohttp-fast-zlib==0.3.0
66
aiosignal==1.4.0
77
akracer==0.0.14
@@ -17,7 +17,7 @@ beautifulsoup4==4.15.0
1717
billiard==4.2.4
1818
blinker==1.9.0
1919
build==1.5.0
20-
ccxt==4.5.70
20+
ccxt==4.5.73
2121
celery==5.6.3
2222
certifi==2026.6.17
2323
cffi==2.0.0
@@ -27,7 +27,7 @@ click-didyoumean==0.3.1
2727
click-plugins==1.1.1.2
2828
click-repl==0.3.0
2929
coincurve==21.0.0
30-
cryptography==49.0.0
30+
cryptography==50.0.0
3131
curl-cffi==0.15.0
3232
decorator==5.3.1
3333
distro==1.9.0
@@ -92,7 +92,7 @@ pygments==2.20.0
9292
pyjwt==2.13.0
9393
pyluach==2.3.0
9494
pyotp==2.10.0
95-
pypdf==6.14.2
95+
pypdf==6.15.0
9696
pyproject-hooks==1.2.0
9797
pysocks==1.7.1
9898
python-dateutil==2.9.0.post0

backend_api_python/requirements.txt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,28 @@ Werkzeug>=3.1.8
44
flask-cors==6.0.5
55
finnhub-python>=2.4.29
66
yfinance>=1.5.2
7-
ccxt>=4.5.69
7+
ccxt>=4.5.73
8+
# GHSA-cq5v-8q36-5273 (C parser OOB read), GHSA-mfx4-hv73-q22v and
9+
# GHSA-mq44-7p77-q5h7 (WebSocket parsing); 3.14.3+
10+
aiohttp>=3.14.3,<3.15
811
pandas>=3.0.5
912
# Strategy API V2 technical indicator runtime. The 0.6.x wheels bundle the C library.
1013
TA-Lib==0.7.1
1114
exchange-calendars>=4.13.2,<5
1215
requests>=2.34.2
1316
websocket-client>=1.9.0,<2
1417
litellm>=1.93.0,<1.94
15-
# ccxt 4.5.69/4.5.70 pins certifi==2026.6.17. Keep the direct floor
18+
# ccxt 4.5.73 pins certifi==2026.6.17. Keep the direct floor
1619
# aligned with the exchange client until ccxt relaxes its constraint.
1720
certifi>=2026.6.17
1821
PySocks>=1.7.1
1922
akshare>=1.18.80
2023
# GHSA-752w-5fwx-jx9f (crit header); 2.12.0+
2124
PyJWT>=2.13.0,<3
2225
python-dotenv>=1.2.2
23-
cryptography>=49.0.0
26+
# GHSA-g6cj-pr64-35w5 (PKCS#7 Bleichenbacher oracle); 50.0.0+
27+
# ccxt 4.5.73 currently constrains cryptography to the 50.x release line.
28+
cryptography>=50.0.0,<51
2429
# TOTP MFA and QR code generation
2530
pyotp>=2.10.0
2631
qrcode[pil]>=8.2
@@ -41,7 +46,8 @@ flask-smorest>=0.47.0,<0.48
4146
marshmallow>=4.3.0,<5
4247
PyYAML>=6.0.3
4348
# Server-side PDF export for AI analysis reports
44-
pypdf>=6.14.2,<7
49+
# GHSA-fp3f-mc75-235c / GHSA-fwg2-594c-jp42 (unbounded PDF resource use); 6.15.0+
50+
pypdf>=6.15.0,<7
4551
reportlab>=5.0.0
4652
# Password hashing
4753
bcrypt>=5.0.0

0 commit comments

Comments
 (0)