Skip to content

Commit ec129b5

Browse files
authored
feat(strategy): major strategy system improvements and optimizations (#37)
# Squash Merge Commit Message ## Title feat(strategy): major strategy system improvements and optimizations ## Body Major improvements to the PyneCore strategy system with enhanced performance and functionality. ### Key Features - Implemented PriceOrderBook for O(log n) price-based order operations - Added complete OCA (One-Cancels-All) group support with cancel/reduce functionality - Enhanced risk management with max intraday filled orders tracking - Migrated comprehensive slippage test suite from PyneComp ### Bug Fixes - Fixed commission calculations for percentage-based commissions - Corrected NA handling in qty validation - Fixed order processing logic for stop/limit orders - Resolved pyramiding logic issues ### Performance Optimizations - Optimized Position class with __slots__ for 20-40% memory reduction - Improved order book operations with bisect-based price level management - Enhanced bar_index tracking for better debugging ### Testing - Added 4 new slippage test strategies with full CSV validation - All existing tests pass with improved performance - Trade validation matches TradingView behavior exactly Closes #37
1 parent a0b4637 commit ec129b5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+146146
-31608
lines changed

src/pynecore/core/overload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def dispatcher(*args: Any, **kwargs: Any) -> Any:
141141
except TypeError:
142142
continue
143143

144-
raise TypeError(f"No matching implementation found for {qualname}")
144+
raise TypeError(f"No matching implementation found for {qualname}: {args}, {kwargs}")
145145

146146
# Store implementation and dispatcher
147147
_registry[qualname].append(impl)

src/pynecore/core/safe_convert.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from ..types import NA
22

33

4-
def safe_div(a, b):
4+
def safe_div(a: float | NA[float], b: float | NA[float]):
55
"""
66
Safe division that returns NA for division by zero.
77
Mimics Pine Script behavior where division by zero returns NA.
@@ -20,7 +20,7 @@ def safe_div(a, b):
2020
return NA(float)
2121

2222

23-
def safe_float(value):
23+
def safe_float(value: float | NA[float]) -> float | NA[float]:
2424
"""
2525
Safe float conversion that returns NA for NA inputs.
2626
Catches TypeError (thrown by NA values) but allows ValueError to propagate normally.
@@ -35,7 +35,7 @@ def safe_float(value):
3535
return NA(float)
3636

3737

38-
def safe_int(value):
38+
def safe_int(value: int | NA[int]) -> int | NA[int]:
3939
"""
4040
Safe int conversion that returns NA for NA inputs.
4141
Catches TypeError (thrown by NA values) but allows ValueError to propagate normally.

src/pynecore/core/script.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
TEnum = TypeVar('TEnum', bound=StrEnum)
3131

3232

33-
@dataclass(kw_only=True)
33+
@dataclass(kw_only=True, slots=True)
3434
class InputData:
3535
"""
3636
Input dataclass
@@ -50,12 +50,12 @@ class InputData:
5050
display: _display.Display | None = None
5151

5252

53-
old_input_values: dict[str, Any] = {}
53+
_old_input_values: dict[str, Any] = {}
5454
inputs: dict[str | None, InputData] = {}
5555

5656

5757
# noinspection PyShadowingBuiltins,PyShadowingNames
58-
@dataclass(kw_only=True)
58+
@dataclass(kw_only=True, slots=True)
5959
class Script:
6060
"""
6161
Script parameters dataclass
@@ -73,7 +73,7 @@ class Script:
7373
format: _format.Format = _format.inherit
7474
precision: int | None = None
7575
scale: _scale.Scale | None = None
76-
pyramiding: int = 0
76+
pyramiding: int = 1
7777
calc_on_order_fills: bool = False
7878
calc_on_every_tick: bool = False
7979
max_bars_back: int = 0
@@ -137,7 +137,10 @@ def _format_value(value) -> str:
137137
]
138138

139139
# Save general settings
140-
for key, value in self.__dict__.items():
140+
from dataclasses import fields
141+
for field in fields(self):
142+
key = field.name
143+
value = getattr(self, key)
141144
if key.startswith('_') or key in self._SKIP_FIELDS:
142145
continue
143146
if value is None:
@@ -153,11 +156,17 @@ def _format_value(value) -> str:
153156

154157
# Save inputs
155158
for arg_name, input_data in self.inputs.items():
159+
# Skip None keys (can happen with some input configurations)
160+
if arg_name is None:
161+
continue
156162
lines.append(f"\n[inputs.{arg_name.removesuffix('__global__')}]")
157163
lines.append("# Input metadata, cannot be modified")
158164

159165
# Add all metadata as comments
160-
for key, value in input_data.__dict__.items():
166+
from dataclasses import fields as input_fields
167+
for field in input_fields(input_data):
168+
key = field.name
169+
value = getattr(input_data, key)
161170
if key == 'id':
162171
continue
163172
if value is not None:
@@ -167,8 +176,8 @@ def _format_value(value) -> str:
167176
lines.append("# Change here to modify the input value")
168177

169178
# Add the actual value
170-
if input_data.defval is not None and arg_name in old_input_values:
171-
lines.append(f"value = {_format_value(old_input_values[arg_name])}")
179+
if input_data.defval is not None and arg_name in _old_input_values:
180+
lines.append(f"value = {_format_value(_old_input_values[arg_name])}")
172181
else:
173182
lines.append("#value =")
174183

@@ -207,8 +216,8 @@ def load(self, path: str | Path) -> None:
207216
for arg_name, arg_data in data['inputs'].items():
208217
if 'value' not in arg_data:
209218
continue
210-
old_input_values[arg_name] = arg_data['value']
211-
old_input_values[arg_name + '__global__'] = arg_data['value'] # For strict mode
219+
_old_input_values[arg_name] = arg_data['value']
220+
_old_input_values[arg_name + '__global__'] = arg_data['value'] # For strict mode
212221

213222
#
214223
# decorators
@@ -223,6 +232,10 @@ def _decorate(self):
223232
if toml_path.exists():
224233
self.load(toml_path)
225234

235+
# Pyramiding must be at least 1
236+
if self.pyramiding <= 0:
237+
self.pyramiding = 1
238+
226239
def decorator(func):
227240
# Save inputs to script instance then clear inputs (for next script)
228241
self.inputs = inputs.copy() # type: ignore
@@ -236,7 +249,7 @@ def decorator(func):
236249
if os.environ.get('PYNE_SAVE_SCRIPT_TOML', '1') == '1' and 'pytest' not in sys.modules:
237250
self.save(toml_path)
238251

239-
old_input_values.clear()
252+
_old_input_values.clear()
240253
return func
241254

242255
return decorator
@@ -523,7 +536,7 @@ def __call__(self, defval: Any, title: str | None = None, *,
523536
group=group,
524537
display=display,
525538
)
526-
return defval if _id not in old_input_values else old_input_values[_id]
539+
return defval if _id not in _old_input_values else _old_input_values[_id]
527540

528541
@classmethod
529542
def _int(cls, defval: int, title: str | None = None, *,
@@ -564,7 +577,7 @@ def _int(cls, defval: int, title: str | None = None, *,
564577
options=options,
565578
display=display,
566579
)
567-
return defval if _id not in old_input_values else safe_convert.safe_int(old_input_values[_id])
580+
return defval if _id not in _old_input_values else safe_convert.safe_int(_old_input_values[_id])
568581

569582
@classmethod
570583
def _bool(cls, defval: bool, title: str | None = None, *,
@@ -596,7 +609,7 @@ def _bool(cls, defval: bool, title: str | None = None, *,
596609
confirm=confirm,
597610
display=display,
598611
)
599-
return defval if _id not in old_input_values else bool(old_input_values[_id])
612+
return defval if _id not in _old_input_values else bool(_old_input_values[_id])
600613

601614
@classmethod
602615
def _float(cls, defval: float, title: str | None = None, *,
@@ -637,7 +650,7 @@ def _float(cls, defval: float, title: str | None = None, *,
637650
options=options,
638651
display=display,
639652
)
640-
return defval if _id not in old_input_values else safe_convert.safe_float(old_input_values[_id])
653+
return defval if _id not in _old_input_values else safe_convert.safe_float(_old_input_values[_id])
641654

642655
@classmethod
643656
def string(cls, defval: str, title: str | None = None, *,
@@ -673,7 +686,7 @@ def string(cls, defval: str, title: str | None = None, *,
673686
display=display,
674687
options=options,
675688
)
676-
return defval if _id not in old_input_values else str(old_input_values[_id])
689+
return defval if _id not in _old_input_values else str(_old_input_values[_id])
677690

678691
@classmethod
679692
def color(cls, defval: Color, title: str | None = None, *,
@@ -705,7 +718,7 @@ def color(cls, defval: Color, title: str | None = None, *,
705718
confirm=confirm,
706719
display=display,
707720
)
708-
return defval if _id not in old_input_values else Color(old_input_values[_id])
721+
return defval if _id not in _old_input_values else Color(_old_input_values[_id])
709722

710723
@classmethod
711724
def source(cls, defval: str | Source, title: str | None = None, *,
@@ -740,7 +753,7 @@ def source(cls, defval: str | Source, title: str | None = None, *,
740753
display=display,
741754
)
742755
# We actually return a string here, but the InputTransformer will add a `getattr()` call to get the
743-
return cast(Series[float], defval if _id not in old_input_values else old_input_values[_id])
756+
return cast(Series[float], defval if _id not in _old_input_values else _old_input_values[_id])
744757

745758
@classmethod
746759
def enum(cls, defval: TEnum, title: str | None = None, *,
@@ -776,11 +789,11 @@ def enum(cls, defval: TEnum, title: str | None = None, *,
776789
display=display,
777790
options=options,
778791
)
779-
if _id not in old_input_values:
792+
if _id not in _old_input_values:
780793
return defval
781794
else:
782795
# Convert string value back to the specific enum type
783-
value = old_input_values[_id]
796+
value = _old_input_values[_id]
784797
if isinstance(value, str):
785798
try:
786799
return defval.__class__(value)

src/pynecore/core/script_runner.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def _round_price(price: float, lib: ModuleType):
7272
from .. import lib
7373
syminfo = lib.syminfo
7474
scaled = round(price * syminfo.pricescale)
75-
return scaled * syminfo.mintick
75+
return scaled / syminfo.pricescale
7676

7777

7878
# noinspection PyShadowingNames
@@ -108,10 +108,11 @@ def _set_lib_syminfo_properties(syminfo: SymInfo, lib: ModuleType):
108108
if TYPE_CHECKING: # This is needed for the type checker to work
109109
from .. import lib
110110

111-
for key, value in syminfo.__dict__.items():
111+
for slot_name in syminfo.__slots__: # type: ignore
112+
value = getattr(syminfo, slot_name)
112113
if value is not None:
113114
try:
114-
setattr(lib.syminfo, key, value)
115+
setattr(lib.syminfo, slot_name, value)
115116
except AttributeError:
116117
pass
117118

@@ -396,7 +397,7 @@ def run_iter(self, on_progress: Callable[[datetime], None] | None = None) \
396397
if self.trades_writer and position.open_trades:
397398
for trade in position.open_trades:
398399
trade_num += 1 # Continue numbering from closed trades
399-
# Export only the entry part for open trades
400+
# Export the entry part
400401
self.trades_writer.write(
401402
trade_num,
402403
trade.entry_bar_index,
@@ -415,6 +416,37 @@ def run_iter(self, on_progress: Callable[[datetime], None] | None = None) \
415416
"0.00", # No max drawdown percent yet
416417
)
417418

419+
# Export the exit part with "Open" signal (TradingView compatibility)
420+
# This simulates automatic closing at the end of backtest
421+
# Use the last price from the iteration
422+
exit_price = self.last_price
423+
424+
if exit_price is not None:
425+
# Calculate profit/loss using the same formula as Position._fill_order
426+
# For closing, size is negative of the position
427+
closing_size = -trade.size
428+
pnl = -closing_size * (exit_price - trade.entry_price)
429+
pnl_percent = (pnl / (trade.entry_price * abs(trade.size))) * 100 \
430+
if trade.entry_price != 0 else 0
431+
432+
self.trades_writer.write(
433+
trade_num,
434+
self.bar_index - 1, # Last bar index
435+
"Exit long" if trade.size > 0 else "Exit short",
436+
"Open", # TradingView uses "Open" signal for automatic closes
437+
string.format_time(lib._time), # type: ignore
438+
exit_price,
439+
abs(trade.size),
440+
pnl,
441+
f"{pnl_percent:.2f}",
442+
pnl, # Same as profit for last trade
443+
f"{pnl_percent:.2f}",
444+
max(0.0, pnl), # Runup
445+
f"{max(0, pnl_percent):.2f}",
446+
max(0.0, -pnl), # Drawdown
447+
f"{max(0, -pnl_percent):.2f}",
448+
)
449+
418450
# Write strategy statistics
419451
if self.strat_writer and position:
420452
try:

src/pynecore/core/syminfo.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
SymInfoSession = NamedTuple("SymInfoSession", [('day', int), ('time', time)])
88

99

10-
@dataclass(kw_only=True)
10+
@dataclass(kw_only=True, slots=True)
1111
class SymInfo:
1212
"""
1313
Symbol information dataclass
@@ -41,7 +41,7 @@ class SymInfo:
4141
avg_spread: float | None = None
4242
taker_fee: float | None = None
4343
maker_fee: float | None = None
44-
44+
4545
# Analyst price target information (added 2025-07-08)
4646
target_price_average: float | None = None
4747
target_price_high: float | None = None

src/pynecore/lib/log.py

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def render(self, *, record, traceback, message_renderable):
2929
"""Override render to use Pine Script time and timezone"""
3030
from ..types import NA
3131
from datetime import UTC
32+
from rich.text import Text
33+
from rich.table import Table
3234

3335
# Get the datetime in the correct timezone
3436
if lib._time:
@@ -43,22 +45,49 @@ def render(self, *, record, traceback, message_renderable):
4345
# No Pine time, use current time
4446
log_time = datetime.fromtimestamp(record.created)
4547

46-
# Call parent's _log_render with our log_time
48+
# Format the time
4749
path = Path(record.pathname).name
4850
level = self.get_level_text(record)
4951
time_format = None if self.formatter is None else self.formatter.datefmt
5052

51-
log_renderable = self._log_render(
52-
self.console,
53-
[message_renderable] if not traceback else [message_renderable, traceback],
54-
log_time=log_time,
55-
time_format=time_format,
56-
level=level,
57-
path=path,
58-
line_no=record.lineno,
59-
link_path=record.pathname if self.enable_link_path else None,
60-
)
61-
return log_renderable
53+
# Create custom log output with colored bar_index
54+
if hasattr(lib, 'bar_index') and lib.bar_index is not None:
55+
# Format the base time
56+
time_str = log_time.strftime(time_format or "[%Y-%m-%d %H:%M:%S]")
57+
58+
# Create table for log output
59+
output = Table.grid(padding=(0, 1))
60+
output.expand = True
61+
62+
# Add columns - no style for first two columns to allow Text styling
63+
output.add_column() # Time column
64+
output.add_column() # Bar index column
65+
output.add_column(style="log.level", width=8) # Level column
66+
output.add_column(ratio=1, style="log.message", overflow="fold") # Message column
67+
68+
# Build row with styled Text objects
69+
row = [
70+
Text(time_str, style="log.time"),
71+
Text(f"bar: {lib.bar_index:6}", style="cyan"),
72+
level,
73+
message_renderable,
74+
]
75+
76+
output.add_row(*row)
77+
return output
78+
else:
79+
# Standard rendering without bar_index
80+
log_renderable = self._log_render(
81+
self.console,
82+
[message_renderable] if not traceback else [message_renderable, traceback],
83+
log_time=log_time,
84+
time_format=time_format,
85+
level=level,
86+
path=path,
87+
line_no=record.lineno,
88+
link_path=record.pathname if self.enable_link_path else None,
89+
)
90+
return log_renderable
6291

6392

6493
# noinspection PyProtectedMember
@@ -85,9 +114,15 @@ def formatTime(self, record: logging.LogRecord, datefmt: str = None) -> str:
85114

86115
# Format the datetime
87116
if datefmt:
88-
return dt.strftime(datefmt)
117+
time_str = dt.strftime(datefmt)
89118
else:
90-
return dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
119+
time_str = dt.strftime("%Y-%m-%d %H:%M:%S,%f")[:-3]
120+
121+
# Add bar_index to the time string
122+
if hasattr(lib, 'bar_index') and lib.bar_index is not None:
123+
time_str = f"{time_str} bar: {lib.bar_index:6}"
124+
125+
return time_str
91126

92127
def format(self, record: logging.LogRecord) -> Any:
93128
"""Format log record in Pine style: [timestamp]: message"""

0 commit comments

Comments
 (0)