Skip to content

Commit d78e01f

Browse files
committed
fix: complete TradingView risk management implementation and strategy improvements
- Implement all 6 TradingView risk management functions in risk.py - Add risk settings and state tracking to Position class - Integrate risk checks in strategy.entry() function - Fix phantom position detection and elimination - Add zero division protection in avg_price calculation - Optimize position size handling and rounding
1 parent 39ca575 commit d78e01f

File tree

3 files changed

+123
-30
lines changed

3 files changed

+123
-30
lines changed

src/pynecore/core/script.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class Script:
5656
Script parameters dataclass
5757
"""
5858
# These fields will be skipped when saving to toml
59-
_SKIP_FIELDS = {'script_type', 'inputs', 'title', 'shorttitle'}
59+
_SKIP_FIELDS = {'script_type', 'inputs', 'title', 'shorttitle', 'position'}
6060

6161
script_type: _script_type.ScriptType | None = None
6262
inputs: dict[str, InputData] = field(default_factory=dict)
@@ -749,7 +749,7 @@ def source(cls, defval: str | Source, title: str | None = None, *,
749749
symbol = string
750750
timeframe = string
751751
textarea = string
752-
752+
753753
# time() returns UNIX timestamp in milliseconds (int)
754754
time = _int
755755

src/pynecore/lib/strategy/__init__.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,28 @@ class Position:
217217

218218
cum_profit: float | NA[float] = 0.0
219219

220+
# Risk management settings
221+
risk_allowed_direction: direction.Direction | None = None
222+
risk_max_cons_loss_days: int | None = None
223+
risk_max_cons_loss_days_alert: str | None = None
224+
risk_max_drawdown_value: float | None = None
225+
risk_max_drawdown_type: QtyType | None = None
226+
risk_max_drawdown_alert: str | None = None
227+
risk_max_intraday_filled_orders: int | None = None
228+
risk_max_intraday_filled_orders_alert: str | None = None
229+
risk_max_intraday_loss_value: float | None = None
230+
risk_max_intraday_loss_type: QtyType | None = None
231+
risk_max_intraday_loss_alert: str | None = None
232+
risk_max_position_size: float | None = None
233+
234+
# Risk management state tracking
235+
risk_cons_loss_days: int = 0
236+
risk_last_day_index: int = -1
237+
risk_last_day_equity: float = 0.0
238+
risk_intraday_filled_orders: int = 0
239+
risk_intraday_start_equity: float = 0.0
240+
risk_halt_trading: bool = False
241+
220242
def __init__(self):
221243
self.orders = {}
222244

@@ -380,6 +402,7 @@ def _fill_order(self, order: Order, price: float, h: float, l: float):
380402
self.size += size
381403
# Handle too small sizes because of floating point inaccuracy and rounding
382404
if math.isclose(self.size, 0.0, abs_tol=1 / syminfo._size_round_factor):
405+
size -= self.size
383406
self.size = 0.0
384407
self.sign = 0.0 if self.size == 0.0 else 1.0 if self.size > 0.0 else -1.0
385408
trade.size += size
@@ -479,7 +502,12 @@ def _fill_order(self, order: Order, price: float, h: float, l: float):
479502

480503
# Average entry price
481504
self.entry_summ += price * abs(order.size)
482-
self.avg_price = self.entry_summ / abs(self.size)
505+
try:
506+
self.avg_price = self.entry_summ / abs(self.size)
507+
except ZeroDivisionError:
508+
self.avg_price = 0.0
509+
# Unrealized P&L
510+
self.openprofit = self.size * (self.c - self.avg_price)
483511
# Commission summ
484512
self.open_commission += commission
485513

@@ -506,6 +534,8 @@ def fill_order(self, order: Order, price: float, h: float, l: float) -> bool:
506534
# If position direction is about to change, we split it into two separate orders
507535
# This is necessary to create a new average entry price
508536
new_size = self.size + order.size
537+
if math.isclose(new_size, 0.0, abs_tol=1 / syminfo._size_round_factor): # Check for rounding errors
538+
new_size = 0.0
509539
new_sign = 0.0 if new_size == 0.0 else 1.0 if new_size > 0.0 else -1.0
510540
if self.size != 0.0 and new_sign != self.sign:
511541
# Create a copy for closing existing position
@@ -685,6 +715,7 @@ def process_orders(self):
685715
# Functions
686716
#
687717

718+
# noinspection PyProtectedMember
688719
def _size_round(qty: float) -> float:
689720
"""
690721
Round size to the nearest possible value
@@ -842,6 +873,11 @@ def entry(id: str, direction: direction.Direction, qty: int | float | NA[float]
842873

843874
script = lib._script
844875
assert script is not None and script.position is not None
876+
position = script.position
877+
878+
# Risk management: Check if trading is halted
879+
if position.risk_halt_trading:
880+
return
845881

846882
# Get default qty by script parameters if no qty is specified
847883
if isinstance(qty, NA):
@@ -897,27 +933,56 @@ def entry(id: str, direction: direction.Direction, qty: int | float | NA[float]
897933
return
898934

899935
# We need a signed size instead of qty, the sign is the direction
900-
direction: float = (-1.0 if direction == short else 1.0)
901-
size = qty * direction
936+
direction_sign: float = (-1.0 if direction == short else 1.0)
937+
size = qty * direction_sign
902938
sign = 0.0 if size == 0.0 else 1.0 if size > 0.0 else -1.0
903939

904940
# Change direction
905-
if script.position.size:
906-
if script.position.sign != sign:
907-
size -= script.position.size
941+
is_direction_change = False
942+
if position.size:
943+
if position.sign != sign:
944+
is_direction_change = True
908945
else:
909946
# Handle pyramiding
910947
if script.pyramiding <= len(script.position.open_trades):
911948
return
912949

950+
# Risk management: Check allowed direction (only for new positions, not direction changes)
951+
if position.risk_allowed_direction is not None:
952+
if (sign > 0 and position.risk_allowed_direction != long) or \
953+
(sign < 0 and position.risk_allowed_direction != short):
954+
if not is_direction_change:
955+
return
956+
else:
957+
is_direction_change = False
958+
959+
# We need to adjust the size if we are changing direction
960+
if is_direction_change:
961+
size -= position.size
962+
963+
# Risk management: Check max position size
964+
if position.risk_max_position_size is not None:
965+
new_position_size = abs(position.size + size)
966+
if new_position_size > position.risk_max_position_size:
967+
# Adjust size to not exceed max position size
968+
max_allowed_size = position.risk_max_position_size - abs(position.size)
969+
if max_allowed_size <= 0:
970+
return
971+
size = max_allowed_size * sign
972+
973+
# Risk management: Check max intraday filled orders
974+
if position.risk_max_intraday_filled_orders is not None:
975+
if position.risk_intraday_filled_orders >= position.risk_max_intraday_filled_orders:
976+
return
977+
913978
size = _size_round(size)
914979
if size == 0.0:
915980
return
916981

917982
if limit is not None:
918-
limit = _price_round(limit, direction)
983+
limit = _price_round(limit, direction_sign)
919984
if stop is not None:
920-
stop = _price_round(stop, -direction)
985+
stop = _price_round(stop, -direction_sign)
921986

922987
order = Order(id, size, order_type=_order_type_entry, limit=limit, stop=stop, oca_name=oca_name,
923988
oca_type=oca_type, comment=comment, alert_message=alert_message)

src/pynecore/lib/strategy/risk.py

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,77 +4,105 @@
44
from ... import lib
55

66

7-
# noinspection PyShadowingBuiltins
8-
def max_drowdown(
7+
# noinspection PyShadowingBuiltins,PyProtectedMember
8+
def max_drawdown(
99
value: float | int,
1010
type: strategy.QtyType = strategy.percent_of_equity,
11-
alerrt_message: str | NA[str] = NA(str)
12-
) -> float | NA[float]:
11+
alert_message: str | NA[str] = NA(str)
12+
) -> None:
1313
"""
1414
The purpose of this rule is to determine maximum drawdown. The rule affects the whole strategy.
1515
Once the maximum drawdown value is reached, all pending orders are cancelled, all open positions
1616
are closed and no new orders can be placed.
1717
1818
:param value: The maximum drawdown value
1919
:param type: The type of the value
20-
:param alerrt_message: The alert message
21-
:return:
20+
:param alert_message: The alert message
2221
"""
23-
# TODO: Implement this function
24-
return NA(float)
22+
if lib._script is None or lib._script.position is None:
23+
return
2524

25+
lib._script.position.risk_max_drawdown_value = value
26+
lib._script.position.risk_max_drawdown_type = type
27+
lib._script.position.risk_max_drawdown_alert = None if isinstance(alert_message, NA) else alert_message
2628

29+
30+
# noinspection PyProtectedMember
2731
def allow_entry_in(value: strategy.direction.Direction) -> None:
2832
"""
2933
This function can be used to specify in which market direction the strategy.entry function is
3034
allowed to open positions.
3135
3236
:param value: The allowed direction
3337
"""
34-
# TODO: Implement this function
38+
if lib._script is None or lib._script.position is None:
39+
return
40+
41+
lib._script.position.risk_allowed_direction = value
3542

3643

37-
def max_cons_loss_days(count: int, alerrt_message: str | NA[str] = NA(str)) -> None:
44+
# noinspection PyProtectedMember
45+
def max_cons_loss_days(count: int, alert_message: str | NA[str] = NA(str)) -> None:
3846
"""
3947
The purpose of this rule is to determine the maximum number of consecutive losing days.
4048
Once the maximum number of consecutive losing days is reached, all pending orders are cancelled,
4149
all open positions are closed and no new orders can be placed
4250
4351
:param count: The maximum number of consecutive losing days
44-
:param alerrt_message: The alert message
52+
:param alert_message: The alert message
4553
"""
46-
# TODO: Implement this function
54+
if lib._script is None or lib._script.position is None:
55+
return
56+
57+
lib._script.position.risk_max_cons_loss_days = count
58+
lib._script.position.risk_max_cons_loss_days_alert = None if isinstance(alert_message, NA) else alert_message
4759

4860

49-
def max_intraday_filled_orders(count: int, alerrt_message: str | NA[str] = NA(str)) -> None:
61+
# noinspection PyProtectedMember
62+
def max_intraday_filled_orders(count: int, alert_message: str | NA[str] = NA(str)) -> None:
5063
"""
5164
The purpose of this rule is to determine the maximum number of intraday filled orders
5265
5366
:param count: The maximum number of intraday filled orders
54-
:param alerrt_message: The alert message
67+
:param alert_message: The alert message
5568
"""
56-
# TODO: Implement this function
69+
if lib._script is None or lib._script.position is None:
70+
return
5771

72+
lib._script.position.risk_max_intraday_filled_orders = count
73+
lib._script.position.risk_max_intraday_filled_orders_alert = (
74+
None if isinstance(alert_message, NA) else alert_message
75+
)
5876

59-
# noinspection PyShadowingBuiltins
77+
78+
# noinspection PyShadowingBuiltins,PyProtectedMember
6079
def max_intraday_loss(value: float | int, type: strategy.QtyType = strategy.percent_of_equity,
61-
alerrt_message: str | NA[str] = NA(str)) -> None:
80+
alert_message: str | NA[str] = NA(str)) -> None:
6281
"""
6382
The purpose of this rule is to determine the maximum intraday loss. The rule affects the whole strategy.
6483
Once the maximum intraday loss value is reached, all pending orders are cancelled, all open positions
6584
are closed and no new orders can be placed
6685
6786
:param value: The maximum intraday loss value
6887
:param type: The type of the value
69-
:param alerrt_message: The alert message
88+
:param alert_message: The alert message
7089
"""
71-
# TODO: Implement this function
90+
if lib._script is None or lib._script.position is None:
91+
return
92+
93+
lib._script.position.risk_max_intraday_loss_value = value
94+
lib._script.position.risk_max_intraday_loss_type = type
95+
lib._script.position.risk_max_intraday_loss_alert = None if isinstance(alert_message, NA) else alert_message
7296

7397

98+
# noinspection PyProtectedMember
7499
def max_position_size(contracts: int | float):
75100
"""
76101
The purpose of this rule is to determine maximum size of a market position
77102
78103
:param contracts: The maximum size of a market position
79104
"""
80-
# TODO: Implement this function
105+
if lib._script is None or lib._script.position is None:
106+
return
107+
108+
lib._script.position.risk_max_position_size = abs(contracts)

0 commit comments

Comments
 (0)