diff --git a/README.md b/README.md index 51fea72..7886a28 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,7 @@ cpp = transpile(pine) print(cpp) # complete C++ source string ``` -The output `#include`s ``, ``, … and -compiles into a `.so` exposing the engine's documented C-ABI. +The output `#include`s ``, ``, …; its `GeneratedStrategy` derives from `pineforge::source::PineStrategyHost` and compiles into a `.so` exposing the engine's documented C-ABI. ## Usage @@ -257,8 +256,9 @@ hosted/embedded use. Email **luis@4pass.com.tw** with your use case for a quote. ## Explicit Pine execution attachment -Generated constructors select `attach_pine_execution_adapter()` before host -metadata when `PINEFORGE_HAS_EXPLICIT_PINE_EXECUTION_ADAPTER_V1` is available. +Generated constructors configure their `PineStrategyConfig` before host +metadata and select `attach_pine_execution_adapter()` when +`PINEFORGE_HAS_EXPLICIT_PINE_EXECUTION_ADAPTER_V1` is available. Its current scope is the Pine intraday cap and retained-parent priority rule. A guarded `enable_pine_intraday_cap()` fallback supports existing cap-only engines; engines with neither capability keep their established defaults. diff --git a/pineforge_codegen/codegen/base.py b/pineforge_codegen/codegen/base.py index d372f41..96bb405 100644 --- a/pineforge_codegen/codegen/base.py +++ b/pineforge_codegen/codegen/base.py @@ -4066,7 +4066,7 @@ def generate(self) -> str: self._emit_lazy_source_clock_helper(lines) # 2. Open class - lines.append("class GeneratedStrategy : public BacktestEngine {") + lines.append("class GeneratedStrategy : public pineforge::source::PineStrategyHost {") lines.append("public:") _script_state_decl_start = len(lines) diff --git a/pineforge_codegen/codegen/emit_top.py b/pineforge_codegen/codegen/emit_top.py index 8c12c36..707bec5 100644 --- a/pineforge_codegen/codegen/emit_top.py +++ b/pineforge_codegen/codegen/emit_top.py @@ -107,7 +107,7 @@ class TopLevelEmitter: Mixed into ``CodeGen``; not intended to be instantiated standalone.""" def _emit_includes(self, lines: list[str]) -> None: - lines.append('#include ') + lines.append('#include ') lines.append('#include ') lines.append('#include ') lines.append('#include ') @@ -803,22 +803,23 @@ def _emit_constructor(self, lines: list[str]) -> None: # This is configuration, deliberately outside script-state reset. ctor_body: list[str] = [ "#if defined(PINEFORGE_HAS_EXPLICIT_PINE_EXECUTION_ADAPTER_V1)", - " pineforge::BacktestEngine::attach_pine_execution_adapter();", + " pineforge::source::PineStrategyHost::attach_pine_execution_adapter();", "#elif defined(PINEFORGE_HAS_EXPLICIT_PINE_CAP_V1)", - " pineforge::BacktestEngine::enable_pine_intraday_cap();", + " pineforge::source::PineStrategyHost::enable_pine_intraday_cap();", "#endif", + " pineforge::source::PineStrategyConfig cfg{};", ] - # Strategy params that map to engine members + # Strategy params that map to the source host's config surface. sp = self.ctx.strategy_params if sp.get("process_orders_on_close") is True: - ctor_body.append(" process_orders_on_close_ = true;") + ctor_body.append(" cfg.process_orders_on_close = true;") if sp.get("calc_on_order_fills") is True: - ctor_body.append(" calc_on_order_fills_ = true;") + ctor_body.append(" cfg.calc_on_order_fills = true;") if "initial_capital" in sp and isinstance(sp["initial_capital"], (int, float)): - ctor_body.append(f" initial_capital_ = {float(sp['initial_capital'])};") + ctor_body.append(f" cfg.initial_capital = {float(sp['initial_capital'])};") # default_qty_type: strategy.fixed / strategy.percent_of_equity / strategy.cash qty_type_map = { @@ -828,13 +829,15 @@ def _emit_constructor(self, lines: list[str]) -> None: } qty_type = sp.get("default_qty_type") if qty_type in qty_type_map: - ctor_body.append(f" default_qty_type_ = {qty_type_map[qty_type]};") + ctor_body.append( + f" cfg.default_qty_type = static_cast({qty_type_map[qty_type]});" + ) if "default_qty_value" in sp and isinstance(sp["default_qty_value"], (int, float)): - ctor_body.append(f" default_qty_value_ = {float(sp['default_qty_value'])};") + ctor_body.append(f" cfg.default_qty_value = {float(sp['default_qty_value'])};") if "pyramiding" in sp and isinstance(sp["pyramiding"], int): - ctor_body.append(f" pyramiding_ = {sp['pyramiding']};") + ctor_body.append(f" cfg.pyramiding = {sp['pyramiding']};") # commission_type: strategy.commission.percent / .cash_per_order / .cash_per_contract comm_type_map = { @@ -844,31 +847,35 @@ def _emit_constructor(self, lines: list[str]) -> None: } comm_type = sp.get("commission_type") if comm_type in comm_type_map: - ctor_body.append(f" commission_type_ = {comm_type_map[comm_type]};") + ctor_body.append( + f" cfg.commission_type = static_cast({comm_type_map[comm_type]});" + ) if "commission_value" in sp and isinstance(sp["commission_value"], (int, float)): - ctor_body.append(f" commission_value_ = {float(sp['commission_value'])};") + ctor_body.append(f" cfg.commission_value = {float(sp['commission_value'])};") if "slippage" in sp and isinstance(sp["slippage"], (int, float)): - ctor_body.append(f" slippage_ = {int(sp['slippage'])};") + ctor_body.append(f" cfg.slippage = {int(sp['slippage'])};") # margin_long / margin_short: percent of position value required as # equity (default 100 = 1x leverage). When required_margin exceeds # available equity, TV silently rejects the fill — engine mirrors # this in execute_market_entry's FLAT branch. if "margin_long" in sp and isinstance(sp["margin_long"], (int, float)): - ctor_body.append(f" margin_long_ = {float(sp['margin_long'])};") + ctor_body.append(f" cfg.margin_long = {float(sp['margin_long'])};") if "margin_short" in sp and isinstance(sp["margin_short"], (int, float)): - ctor_body.append(f" margin_short_ = {float(sp['margin_short'])};") + ctor_body.append(f" cfg.margin_short = {float(sp['margin_short'])};") # close_entries_rule: "FIFO" (default) or "ANY" if sp.get("close_entries_rule") == "ANY": - ctor_body.append(" close_entries_rule_any_ = true;") + ctor_body.append(" cfg.close_entries_rule_any = true;") # Turn on native source-series history only when the script uses # input.source — otherwise the engine pays nothing per bar. if self._script_has_input_source(): - ctor_body.append(" _src_series_active_ = true;") + ctor_body.append(" cfg.src_series_active = true;") + + ctor_body.append(" configure_pine_strategy(cfg);") if init_parts and ctor_body: lines.append(f" explicit GeneratedStrategy() : {', '.join(init_parts)} {{") @@ -885,26 +892,37 @@ def _emit_constructor(self, lines: list[str]) -> None: lines.append("") lines.append(" void set_strategy_override(const std::string& key, const std::string& value) {") - lines.append(' if (key == "initial_capital") { initial_capital_ = std::stod(value); return; }') - lines.append(' if (key == "commission_value") { commission_value_ = std::stod(value); return; }') - lines.append(' if (key == "default_qty_value") { default_qty_value_ = std::stod(value); return; }') - lines.append(' if (key == "pyramiding") { pyramiding_ = std::stoi(value); return; }') - lines.append(' if (key == "slippage") { slippage_ = std::stoi(value); return; }') - lines.append(' if (key == "process_orders_on_close") { process_orders_on_close_ = (value == "true" || value == "1"); return; }') - lines.append(' if (key == "calc_on_order_fills") { calc_on_order_fills_ = (value == "true" || value == "1"); return; }') - lines.append(' if (key == "close_entries_rule") { close_entries_rule_any_ = (value == "ANY" || value == "any" || value == "1"); return; }') - lines.append(' if (key == "default_qty_type") {') - lines.append(' if (value == "fixed" || value == "strategy.fixed" || value == "0") default_qty_type_ = QtyType::FIXED;') - lines.append(' else if (value == "percent_of_equity" || value == "strategy.percent_of_equity" || value == "1") default_qty_type_ = QtyType::PERCENT_OF_EQUITY;') - lines.append(' else if (value == "cash" || value == "strategy.cash" || value == "2") default_qty_type_ = QtyType::CASH;') - lines.append(" return;") - lines.append(" }") - lines.append(' if (key == "commission_type") {') - lines.append(' if (value == "percent" || value == "strategy.commission.percent" || value == "0") commission_type_ = CommissionType::PERCENT;') - lines.append(' else if (value == "cash_per_order" || value == "strategy.commission.cash_per_order" || value == "1") commission_type_ = CommissionType::CASH_PER_ORDER;') - lines.append(' else if (value == "cash_per_contract" || value == "strategy.commission.cash_per_contract" || value == "2") commission_type_ = CommissionType::CASH_PER_CONTRACT;') + lines.append(" pineforge::source::StrategyOverrides overrides{};") + lines.append(' if (key == "initial_capital") {') + lines.append(" overrides.initial_capital = std::stod(value);") + lines.append(' } else if (key == "commission_value") {') + lines.append(" overrides.commission_value = std::stod(value);") + lines.append(' } else if (key == "default_qty_value") {') + lines.append(" overrides.default_qty_value = std::stod(value);") + lines.append(' } else if (key == "pyramiding") {') + lines.append(" overrides.pyramiding = std::stoi(value);") + lines.append(' } else if (key == "slippage") {') + lines.append(" overrides.slippage = std::stoi(value);") + lines.append(' } else if (key == "process_orders_on_close") {') + lines.append(' overrides.process_orders_on_close = (value == "true" || value == "1");') + lines.append(' } else if (key == "calc_on_order_fills") {') + lines.append(' overrides.calc_on_order_fills = (value == "true" || value == "1");') + lines.append(' } else if (key == "close_entries_rule") {') + lines.append(' overrides.close_entries_rule = (value == "ANY" || value == "any" || value == "1");') + lines.append(' } else if (key == "default_qty_type") {') + lines.append(' if (value == "fixed" || value == "strategy.fixed" || value == "0") overrides.default_qty_type = static_cast(QtyType::FIXED);') + lines.append(' else if (value == "percent_of_equity" || value == "strategy.percent_of_equity" || value == "1") overrides.default_qty_type = static_cast(QtyType::PERCENT_OF_EQUITY);') + lines.append(' else if (value == "cash" || value == "strategy.cash" || value == "2") overrides.default_qty_type = static_cast(QtyType::CASH);') + lines.append(" else return;") + lines.append(' } else if (key == "commission_type") {') + lines.append(' if (value == "percent" || value == "strategy.commission.percent" || value == "0") overrides.commission_type = static_cast(CommissionType::PERCENT);') + lines.append(' else if (value == "cash_per_order" || value == "strategy.commission.cash_per_order" || value == "1") overrides.commission_type = static_cast(CommissionType::CASH_PER_ORDER);') + lines.append(' else if (value == "cash_per_contract" || value == "strategy.commission.cash_per_contract" || value == "2") overrides.commission_type = static_cast(CommissionType::CASH_PER_CONTRACT);') + lines.append(" else return;") + lines.append(" } else {") lines.append(" return;") lines.append(" }") + lines.append(" pineforge::source::PineStrategyHost::set_strategy_override(overrides);") lines.append(" }") if self._security_eval_info: @@ -981,7 +999,7 @@ def _emit_on_bar(self, lines: list[str]) -> None: self._lexical_udt_types = {} self._lexical_series_bindings = {} self._lexical_known_var_tombstones = set() - lines.append(" void on_bar(const Bar& bar) override {") + lines.append(" void on_source_bar(const Bar& bar) override {") # A GeneratedStrategy handle may execute multiple batch runs or # streaming lifecycles. BacktestEngine resets broker/base state, but diff --git a/pineforge_codegen/codegen/visit_stmt.py b/pineforge_codegen/codegen/visit_stmt.py index c006d9f..207cf05 100644 --- a/pineforge_codegen/codegen/visit_stmt.py +++ b/pineforge_codegen/codegen/visit_stmt.py @@ -346,35 +346,44 @@ def _visit_stmt(self, node: ASTNode, lines: list[str], indent: int) -> None: and node.expr.args): risk_func = c.member _RISK_MEMBER_MAP = { - "max_intraday_filled_orders": ("max_intraday_filled_orders_", "int"), - "max_drawdown": ("risk_max_drawdown_", "double"), - "max_intraday_loss": ("risk_max_intraday_loss_", "double"), - "max_position_size": ("risk_max_position_size_", "double"), - "max_cons_loss_days": ("risk_max_cons_loss_days_", "int"), + "max_intraday_filled_orders": ( + "set_pine_risk_max_intraday_filled_orders", "int" + ), + "max_drawdown": ("set_pine_risk_max_drawdown", "double"), + "max_intraday_loss": ("set_pine_risk_max_intraday_loss", "double"), + "max_position_size": ("set_pine_risk_max_position_size", "double"), + "max_cons_loss_days": ("set_pine_risk_max_cons_loss_days", "int"), } if risk_func == "allow_entry_in": val = self._visit_expr(node.expr.args[0]) if val == "1": - lines.append(f"{pad}risk_direction_ = RiskDirection::LONG_ONLY;") + direction = "1" elif val == "-1": - lines.append(f"{pad}risk_direction_ = RiskDirection::SHORT_ONLY;") + direction = "-1" else: - lines.append(f"{pad}risk_direction_ = RiskDirection::BOTH;") + direction = "0" + lines.append(f"{pad}set_pine_risk_direction({direction});") return if risk_func in _RISK_MEMBER_MAP: - member, cast_type = _RISK_MEMBER_MAP[risk_func] + setter, cast_type = _RISK_MEMBER_MAP[risk_func] val = self._visit_expr(node.expr.args[0]) - lines.append(f"{pad}{member} = ({cast_type})({val});") - # Handle percent_of_equity flag for max_drawdown / max_intraday_loss + # The percent flag travels with the matching setter so + # every risk update atomically replaces both fields. if risk_func in ("max_drawdown", "max_intraday_loss") and len(node.expr.args) >= 2: arg2 = node.expr.args[1] is_pct = (isinstance(arg2, MemberAccess) and isinstance(arg2.object, Identifier) and arg2.object.name == "strategy" and arg2.member == "percent_of_equity") - if is_pct: - pct_flag = "risk_max_drawdown_is_pct_" if risk_func == "max_drawdown" else "risk_max_intraday_loss_is_pct_" - lines.append(f"{pad}{pct_flag} = true;") + else: + is_pct = False + if risk_func in ("max_drawdown", "max_intraday_loss"): + lines.append( + f"{pad}{setter}(({cast_type})({val}), " + f"{'true' if is_pct else 'false'});" + ) + else: + lines.append(f"{pad}{setter}(({cast_type})({val}));") return if self._is_skip_expr(node.expr): return diff --git a/tests/golden/matrix_eigen_pca.cpp b/tests/golden/matrix_eigen_pca.cpp index 5424f63..aa52a4c 100644 --- a/tests/golden/matrix_eigen_pca.cpp +++ b/tests/golden/matrix_eigen_pca.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -149,7 +149,7 @@ struct _PFCheckpointTraits> { } }; -class GeneratedStrategy : public BacktestEngine { +class GeneratedStrategy : public pineforge::source::PineStrategyHost { public: ta::SMA _ta_sma_1; ta::SMA _ta_sma_2; @@ -270,40 +270,53 @@ class GeneratedStrategy : public BacktestEngine { explicit GeneratedStrategy() : _ta_sma_1(14), _ta_sma_2(14), _ta_sma_3(14), _ta_sma_4(14), _ta_sma_5(14), _ta_sma_6(14) { #if defined(PINEFORGE_HAS_EXPLICIT_PINE_EXECUTION_ADAPTER_V1) - pineforge::BacktestEngine::attach_pine_execution_adapter(); + pineforge::source::PineStrategyHost::attach_pine_execution_adapter(); #elif defined(PINEFORGE_HAS_EXPLICIT_PINE_CAP_V1) - pineforge::BacktestEngine::enable_pine_intraday_cap(); + pineforge::source::PineStrategyHost::enable_pine_intraday_cap(); #endif - initial_capital_ = 1000000.0; - default_qty_type_ = QtyType::FIXED; - default_qty_value_ = 1.0; - pyramiding_ = 1; - commission_type_ = CommissionType::PERCENT; - commission_value_ = 0.0; - slippage_ = 0; + pineforge::source::PineStrategyConfig cfg{}; + cfg.initial_capital = 1000000.0; + cfg.default_qty_type = static_cast(QtyType::FIXED); + cfg.default_qty_value = 1.0; + cfg.pyramiding = 1; + cfg.commission_type = static_cast(CommissionType::PERCENT); + cfg.commission_value = 0.0; + cfg.slippage = 0; + configure_pine_strategy(cfg); } void set_strategy_override(const std::string& key, const std::string& value) { - if (key == "initial_capital") { initial_capital_ = std::stod(value); return; } - if (key == "commission_value") { commission_value_ = std::stod(value); return; } - if (key == "default_qty_value") { default_qty_value_ = std::stod(value); return; } - if (key == "pyramiding") { pyramiding_ = std::stoi(value); return; } - if (key == "slippage") { slippage_ = std::stoi(value); return; } - if (key == "process_orders_on_close") { process_orders_on_close_ = (value == "true" || value == "1"); return; } - if (key == "calc_on_order_fills") { calc_on_order_fills_ = (value == "true" || value == "1"); return; } - if (key == "close_entries_rule") { close_entries_rule_any_ = (value == "ANY" || value == "any" || value == "1"); return; } - if (key == "default_qty_type") { - if (value == "fixed" || value == "strategy.fixed" || value == "0") default_qty_type_ = QtyType::FIXED; - else if (value == "percent_of_equity" || value == "strategy.percent_of_equity" || value == "1") default_qty_type_ = QtyType::PERCENT_OF_EQUITY; - else if (value == "cash" || value == "strategy.cash" || value == "2") default_qty_type_ = QtyType::CASH; - return; - } - if (key == "commission_type") { - if (value == "percent" || value == "strategy.commission.percent" || value == "0") commission_type_ = CommissionType::PERCENT; - else if (value == "cash_per_order" || value == "strategy.commission.cash_per_order" || value == "1") commission_type_ = CommissionType::CASH_PER_ORDER; - else if (value == "cash_per_contract" || value == "strategy.commission.cash_per_contract" || value == "2") commission_type_ = CommissionType::CASH_PER_CONTRACT; + pineforge::source::StrategyOverrides overrides{}; + if (key == "initial_capital") { + overrides.initial_capital = std::stod(value); + } else if (key == "commission_value") { + overrides.commission_value = std::stod(value); + } else if (key == "default_qty_value") { + overrides.default_qty_value = std::stod(value); + } else if (key == "pyramiding") { + overrides.pyramiding = std::stoi(value); + } else if (key == "slippage") { + overrides.slippage = std::stoi(value); + } else if (key == "process_orders_on_close") { + overrides.process_orders_on_close = (value == "true" || value == "1"); + } else if (key == "calc_on_order_fills") { + overrides.calc_on_order_fills = (value == "true" || value == "1"); + } else if (key == "close_entries_rule") { + overrides.close_entries_rule = (value == "ANY" || value == "any" || value == "1"); + } else if (key == "default_qty_type") { + if (value == "fixed" || value == "strategy.fixed" || value == "0") overrides.default_qty_type = static_cast(QtyType::FIXED); + else if (value == "percent_of_equity" || value == "strategy.percent_of_equity" || value == "1") overrides.default_qty_type = static_cast(QtyType::PERCENT_OF_EQUITY); + else if (value == "cash" || value == "strategy.cash" || value == "2") overrides.default_qty_type = static_cast(QtyType::CASH); + else return; + } else if (key == "commission_type") { + if (value == "percent" || value == "strategy.commission.percent" || value == "0") overrides.commission_type = static_cast(CommissionType::PERCENT); + else if (value == "cash_per_order" || value == "strategy.commission.cash_per_order" || value == "1") overrides.commission_type = static_cast(CommissionType::CASH_PER_ORDER); + else if (value == "cash_per_contract" || value == "strategy.commission.cash_per_contract" || value == "2") overrides.commission_type = static_cast(CommissionType::CASH_PER_CONTRACT); + else return; + } else { return; } + pineforge::source::PineStrategyHost::set_strategy_override(overrides); } #ifndef PINEFORGE_HAS_SCRIPT_RUN_PREPARE_V1 @@ -339,7 +352,7 @@ class GeneratedStrategy : public BacktestEngine { (void)bars; (void)n; (void)allow_precalculation; } - void on_bar(const Bar& bar) override { + void on_source_bar(const Bar& bar) override { if (!_var_initialized) { m = PineMatrix::new_(2, 2, 0.0); _var_initialized = true; diff --git a/tests/test_calc_on_order_fills_codegen.py b/tests/test_calc_on_order_fills_codegen.py index eae01f2..78dbedb 100644 --- a/tests/test_calc_on_order_fills_codegen.py +++ b/tests/test_calc_on_order_fills_codegen.py @@ -14,11 +14,12 @@ def _strategy(header: str, body: str = "") -> str: def test_calc_on_order_fills_declaration_and_runtime_override_plumbing(): cpp = transpile(_strategy(", calc_on_order_fills=true")) - assert "calc_on_order_fills_ = true;" in cpp + assert "cfg.calc_on_order_fills = true;" in cpp assert ( - 'if (key == "calc_on_order_fills") { calc_on_order_fills_ = ' - '(value == "true" || value == "1"); return; }' + 'overrides.calc_on_order_fills = ' + '(value == "true" || value == "1");' ) in cpp + assert "pineforge::source::PineStrategyHost::set_strategy_override(overrides);" in cpp def test_calc_on_order_fills_false_and_calc_on_every_tick_are_independent(): @@ -33,8 +34,8 @@ def test_calc_on_order_fills_false_and_calc_on_every_tick_are_independent(): every_tick_ctor = every_tick_only.split("explicit GeneratedStrategy()", 1)[ 1 ].split("void set_strategy_override", 1)[0] - assert "calc_on_order_fills_ = true;" not in false_ctor - assert "calc_on_order_fills_ = true;" not in every_tick_ctor + assert "cfg.calc_on_order_fills = true;" not in false_ctor + assert "cfg.calc_on_order_fills = true;" not in every_tick_ctor _ROLLBACK_PROBE = '''//@version=6 @@ -178,7 +179,7 @@ def test_post_fill_recalc_updates_current_history_slot_but_barstate_stays_new(): lowering to ``is_first_tick_``. """ cpp = transpile(_HISTORY_ADVANCE_PROBE) - on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( + on_bar = cpp.split("void on_source_bar(const Bar& bar) override {", 1)[1].split( "\n }", 1 )[0] @@ -288,7 +289,7 @@ def test_inline_history_buffers_are_owned_independent_and_clear_at_bar_zero(): assert len(set(arg_members)) == 4 assert "static thread_local Series" not in cpp - on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( + on_bar = cpp.split("void on_source_bar(const Bar& bar) override {", 1)[1].split( "\n }", 1 )[0] for member in hist_members + arg_members: @@ -439,7 +440,7 @@ def test_every_strategy_history_member_pushes_or_updates(): ) ) cpp = transpile(_strategy(", calc_on_order_fills=true", body)) - on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( + on_bar = cpp.split("void on_source_bar(const Bar& bar) override {", 1)[1].split( "\n }", 1 )[0] diff --git a/tests/test_callable_var_first_reach.py b/tests/test_callable_var_first_reach.py index 0a543fc..2be2f59 100644 --- a/tests/test_callable_var_first_reach.py +++ b/tests/test_callable_var_first_reach.py @@ -381,7 +381,7 @@ def test_callable_conditional_ta_keeps_lifecycle_and_side_effects_in_branch() -> first_reach = reach_count > 0 ? reached.get(0) : -1 ''' cpp = transpile(source) - body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_bar(")] + body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_source_bar(")] assert body.index("if (active) {") < body.index( "if (!this->_pf_var_init_state)" ) @@ -418,7 +418,7 @@ def test_callable_conditional_ta_history_advances_only_on_executed_branch() -> N value = probe(close, bar_index != 1) ''' cpp = transpile(source) - body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_bar(")] + body = cpp[cpp.index("double probe_cs0("):cpp.index(" void on_source_bar(")] assert "if (active) {" in body assert "_hoist_" not in body # The conditional SMA sees bars 0 and 2, so its final value is 20. Pine diff --git a/tests/test_codegen_input_getters.py b/tests/test_codegen_input_getters.py index 532ed6e..c46863d 100644 --- a/tests/test_codegen_input_getters.py +++ b/tests/test_codegen_input_getters.py @@ -110,10 +110,10 @@ def test_input_source_sets_active_flag(): # The ctor turns on the engine's native source-series push only when # the script uses input.source. cpp = _emit('input.source(close, "s")') - assert "_src_series_active_ = true;" in cpp + assert "cfg.src_series_active = true;" in cpp # A script without input.source must NOT pay the cost. no_src = transpile('//@version=6\nstrategy("t")\nplot(close)\n') - assert "_src_series_active_ = true;" not in no_src + assert "cfg.src_series_active = true;" not in no_src # --- get_input_int64 (color) -------------------------------------------- diff --git a/tests/test_codegen_new.py b/tests/test_codegen_new.py index 0e9c004..0a6a911 100644 --- a/tests/test_codegen_new.py +++ b/tests/test_codegen_new.py @@ -86,14 +86,14 @@ def test_run_backtest_full_routes_to_tf_aware_run_when_only_script_tf_set(): def test_includes_present(): cpp = _generate('//@version=6\nstrategy("T")\n') - assert '#include ' in cpp + assert '#include ' in cpp assert '#include ' in cpp def test_class_structure(): cpp = _generate('//@version=6\nstrategy("T")\n') - assert "class GeneratedStrategy : public BacktestEngine" in cpp - assert "void on_bar(const Bar& bar) override" in cpp + assert "class GeneratedStrategy : public pineforge::source::PineStrategyHost" in cpp + assert "void on_source_bar(const Bar& bar) override" in cpp assert 'extern "C"' in cpp assert "strategy_create" in cpp @@ -464,8 +464,8 @@ def test_strategy_entry_forwards_qty_type(): def test_strategy_direction_long_maps_to_long_only_risk(): src = '//@version=6\nstrategy("T")\nstrategy.risk.allow_entry_in(strategy.direction.long)\n' cpp = _generate(src) - assert "risk_direction_ = RiskDirection::LONG_ONLY;" in cpp - assert "risk_direction_ = RiskDirection::SHORT_ONLY;" not in cpp + assert "set_pine_risk_direction(1);" in cpp + assert "set_pine_risk_direction(-1);" not in cpp def test_strategy_close(): @@ -1318,7 +1318,7 @@ def test_runtime_error(): def test_strategy_risk_max_drawdown(): cpp = _generate('//@version=6\nstrategy("T")\nstrategy.risk.max_drawdown(1000)') - assert "risk_max_drawdown_" in cpp + assert "set_pine_risk_max_drawdown((double)(1000), false);" in cpp def test_closed_trade_direction(): diff --git a/tests/test_collection_scope_isolation.py b/tests/test_collection_scope_isolation.py index 1eba40c..e2f7d41 100644 --- a/tests/test_collection_scope_isolation.py +++ b/tests/test_collection_scope_isolation.py @@ -835,7 +835,7 @@ def test_temporal_outer_alias_avoids_user_name_and_routes_subscript() -> None: def test_unique_local_collection_output_hash_is_stable() -> None: cpp = transpile(_IDENTITY_SOURCE) - assert len(cpp) == 13407 + assert len(cpp) == 14048 assert sha256(cpp.encode()).hexdigest() == ( - "40ec6f6159395ec7d63986abe186e69d901fe1d27f7a9a0e4445bd2e2823f6aa" + "3e811b8da0bfa832577a6e261d9f2ee145c6a88368d964d84b9c94cac6ede616" ) diff --git a/tests/test_lazy_edge_ta_every_bar.py b/tests/test_lazy_edge_ta_every_bar.py index 4f78ab1..178077f 100644 --- a/tests/test_lazy_edge_ta_every_bar.py +++ b/tests/test_lazy_edge_ta_every_bar.py @@ -59,7 +59,7 @@ def _cpp(body: str) -> str: def _on_bar(cpp: str) -> str: - return cpp.split("void on_bar(", 1)[1].split("\n }\n", 1)[0] + return cpp.split("void on_source_bar(", 1)[1].split("\n }\n", 1)[0] def _lines(cpp: str) -> list[str]: diff --git a/tests/test_lazy_source_clock.py b/tests/test_lazy_source_clock.py index 300f478..3ac6c6c 100644 --- a/tests/test_lazy_source_clock.py +++ b/tests/test_lazy_source_clock.py @@ -93,7 +93,7 @@ def test_clock_contract_hold_last_base_and_na_guards(): def test_on_bar_resets_then_records_the_held_source_before_statements(): cpp = _cpp("x = close > open and ta.roc(close, 3) > 0") - on_bar = cpp.split("void on_bar(const Bar& bar) override {", 1)[1].split( + on_bar = cpp.split("void on_source_bar(const Bar& bar) override {", 1)[1].split( "\n }", 1 )[0] reset_guard = "if (history_advances_new_bar() && bar_index_ == 0) {" diff --git a/tests/test_map_call_diagnostics.py b/tests/test_map_call_diagnostics.py index b7a4859..1b71979 100644 --- a/tests/test_map_call_diagnostics.py +++ b/tests/test_map_call_diagnostics.py @@ -64,7 +64,7 @@ def test_duplicate_keyword_argument_is_a_parser_compile_error(): def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): cpp = transpile(_VALID_EXISTING_FORMS) assert sha256(cpp.encode()).hexdigest() == ( - "9ea6a36e94a3e4d1adb4d434818a0b5b5daa7f685a3c018e4fc259952342416d" + "6d8475b06b3393521b6be96296c9791aee166380117be0347279869305e7fff6" ) @@ -78,7 +78,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.get("key") observed = probe(map.new()) ''', - "d92b0e040aae029b24bb1feb235c0e39f14ce2c5b47f99d0e54e671828bd2833", + "c3e1ad52f99ed628a7692606e9aef13c50b6dc22ffa9714d0bf71ccdc8060434", ), ( '''//@version=6 @@ -89,7 +89,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.get("key") observed = probe() ''', - "df62d763e697ef67bb2f92322e64438f0bb7908c8407b1d61fedada5b330b5f3", + "69048a24494dc0d684aabecfbffc5b86aba80ce3c26cbae6848e4ede5ca5bda6", ), ( '''//@version=6 @@ -98,7 +98,7 @@ def test_valid_existing_positional_and_typed_keyword_forms_do_not_drift(): map.put("key", 1) observed = map.get("key") ''', - "6d600edd9f11c5078aba3a56777dedbb6deff2159f23854de467b13f4e893fd1", + "2f6db3cfce2f6b908fc6c4485c0187a6418e3599a94be678f4bcb43e1e2b3b90", ), ], ) @@ -141,7 +141,7 @@ def test_security_timeframe_clone_keeps_preceding_map_namespace_source_order(): ) assert sha256(cpp.encode()).hexdigest() == ( - "4e81b54ba0bfe1882e4ed55346da34efd389e96a5d573b9c36ab0dc7dc2667b1" + "74f31ea3937aed127eed7641d0d9a9f23c64e5c746e09596a8a284799f9b3686" ) @@ -159,7 +159,7 @@ def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): cpp = transpile(source, filename="synthetic-lexical-map-source-order.pine") assert sha256(cpp.encode()).hexdigest() == ( - "b7147512adbc6a866538db017efa1c16d80565551969bfd1b1ede74cafc4c004" + "5a087945ff76c28afd2112c7441c43e34436b9f60febf11f37204547a23438ed" ) @@ -204,22 +204,22 @@ def test_security_timeframe_clone_keeps_visible_map_receiver_source_order(): [ ( _LATER_GLOBAL_MAP_SOURCE, - "5d7480713b40fd95d37f505214affc5a05753723618e30ab825f3d28ff11b3e5", + "bc28346812b8fb9427e9394f81fe51ca59565fae6daa505ce09cc55b51a1f042", 34.0, ), ( _NESTED_LEXICAL_MAP_ROOT_SOURCE, - "d4e3d5e6f5f514d97caf08d6f1000f3c752c5619f8c45d2439c26c1d2c12e4e6", + "b305db152684f9e7496f051f075880cbeed620775d5fbb63beb7d765f2091d04", 7.0, ), ( _BLOCK_LOCAL_MAP_ISOLATION_SOURCE, - "f18506b485be32b0f78736b56eeb3544acd8280cce038c56c4bf6a3ff06da2ec", + "1211d22c8be706089ca26b687259aad3a5ac693621eacc6a6ae0a5a23b72fd1e", 923.0, ), ( _FOR_BLOCK_LOCAL_MAP_SOURCE, - "2ce3b32287e2956b519419bd92851493ff76a517bd4e6e5c6c4efa1d0b243e59", + "aa2d0b0195b24dfac6e0dada1f511e3614d25c65a9f1a51b21766c5d4daa23af", 8.0, ), ], diff --git a/tests/test_map_terminal_returns.py b/tests/test_map_terminal_returns.py index c39a6c0..18a508d 100644 --- a/tests/test_map_terminal_returns.py +++ b/tests/test_map_terminal_returns.py @@ -390,7 +390,7 @@ def test_map_terminal_return_forms_compile(): def test_nonterminal_pinemap_output_hash_is_stable(): cpp = transpile(_NONTERMINAL_SOURCE) assert sha256(cpp.encode()).hexdigest() == ( - "4e3586d86e7420f4989341282785507aa703c5beef38ff89e29f029826d7e74f" + "5e3d7b6dc5790842e39bef36eb932d1a6b0596baab068c8ffe2294d277c3a5bf" ) @@ -425,5 +425,5 @@ def test_invalid_terminal_map_shapes_raise_compile_errors(): def test_unresolved_parameter_keeps_lexical_precedence_over_global_map(): cpp = transpile(_SHADOWED_UNRESOLVED_PARAM_SOURCE) assert sha256(cpp.encode()).hexdigest() == ( - "5345f8d43e63fdcd056c11a7e3785144aeb5601b4e27f4e9632360f47f9fb4f8" + "135b9183598b99e6461d1f58b9fa7965c7283abcc21b83ea226e50373aa90885" ) diff --git a/tests/test_method_written_callsite_lifecycle.py b/tests/test_method_written_callsite_lifecycle.py index e274dca..2a84151 100644 --- a/tests/test_method_written_callsite_lifecycle.py +++ b/tests/test_method_written_callsite_lifecycle.py @@ -312,7 +312,7 @@ def test_method_conditional_ta_preserves_delayed_var_first_reach() -> None: ''' cpp = transpile(source) body = cpp[ - cpp.index("double _udt_Holder_sample_cs0("):cpp.index(" void on_bar(") + cpp.index("double _udt_Holder_sample_cs0("):cpp.index(" void on_source_bar(") ] assert body.index("if (active) {") < body.index( "if (!this->_pf_var_init_state)" diff --git a/tests/test_pine_cap_activation.py b/tests/test_pine_cap_activation.py index bc448a1..4e8d32d 100644 --- a/tests/test_pine_cap_activation.py +++ b/tests/test_pine_cap_activation.py @@ -45,9 +45,9 @@ def test_constructor_explicitly_selects_compatibility_with_old_engine_bridge(sou assert cpp.count("attach_pine_execution_adapter();") == 1 assert ( "#if defined(PINEFORGE_HAS_EXPLICIT_PINE_EXECUTION_ADAPTER_V1)\n" - " pineforge::BacktestEngine::attach_pine_execution_adapter();\n" + " pineforge::source::PineStrategyHost::attach_pine_execution_adapter();\n" "#elif defined(PINEFORGE_HAS_EXPLICIT_PINE_CAP_V1)\n" - " pineforge::BacktestEngine::enable_pine_intraday_cap();\n" + " pineforge::source::PineStrategyHost::enable_pine_intraday_cap();\n" "#endif" ) in constructor assert "max_intraday_filled_orders_ =" not in constructor @@ -57,10 +57,10 @@ def test_constructor_explicitly_selects_compatibility_with_old_engine_bridge(sou def test_conditional_risk_limit_is_still_in_on_bar_after_constructor(): cpp = transpile(_SOURCES[2]) select = cpp.index("enable_pine_intraday_cap();") - on_bar = cpp.index("void on_bar(") - statement = cpp.index("max_intraday_filled_orders_ = (int)(limit);") + on_bar = cpp.index("void on_source_bar(") + statement = cpp.index("set_pine_risk_max_intraday_filled_orders((int)(limit));") assert select < on_bar < statement - assert cpp.count("max_intraday_filled_orders_ =") == 1 + assert cpp.count("set_pine_risk_max_intraday_filled_orders(") == 1 prefix = cpp[on_bar:statement] assert "if (" in prefix and "pine_bar_index()" in prefix @@ -69,12 +69,12 @@ def test_multiple_risk_limits_keep_source_order_and_do_not_reset_attachment(): cpp = transpile(_SOURCES[3]) statements = [ line.strip() for line in cpp.splitlines() - if "max_intraday_filled_orders_ =" in line + if "set_pine_risk_max_intraday_filled_orders(" in line ] assert statements == [ - "max_intraday_filled_orders_ = (int)(3);", - "max_intraday_filled_orders_ = (int)(4);", - "max_intraday_filled_orders_ = (int)(3);", + "set_pine_risk_max_intraday_filled_orders((int)(3));", + "set_pine_risk_max_intraday_filled_orders((int)(4));", + "set_pine_risk_max_intraday_filled_orders((int)(3));", ] assert cpp.count("enable_pine_intraday_cap();") == 1 assert cpp.count("attach_pine_execution_adapter();") == 1 @@ -100,7 +100,7 @@ def test_cap_only_bridge_and_new_method_name_shadow(): strategy.risk.max_intraday_filled_orders(attach_pine_execution_adapter) """ cpp = transpile(source) - assert "pineforge::BacktestEngine::attach_pine_execution_adapter();" in cpp + assert "pineforge::source::PineStrategyHost::attach_pine_execution_adapter();" in cpp compile_cpp(cpp, label="pine-execution-method-shadow") # Prove the cap-only branch parses independently of the newer member. compile_cpp("#include \n" diff --git a/tests/test_pinemap_boundaries_and_order.py b/tests/test_pinemap_boundaries_and_order.py index 8befd9e..0a5a389 100644 --- a/tests/test_pinemap_boundaries_and_order.py +++ b/tests/test_pinemap_boundaries_and_order.py @@ -208,7 +208,7 @@ def test_non_map_user_call_remains_exact_baseline_bytes() -> None: cpp = transpile(source) assert "__pf_call_arg_" not in cpp assert sha256(cpp.encode()).hexdigest() == ( - "03259ec28c22ce068da06f4b87d314021e45446b28c15b1785a77c77a3ed1bd4" + "f448daf0f4ded4554d117c74414ea6325e189ff70b3c7f1471c3072a7477c997" ) diff --git a/tests/test_pinemap_semantics.py b/tests/test_pinemap_semantics.py index f2e374f..ae738db 100644 --- a/tests/test_pinemap_semantics.py +++ b/tests/test_pinemap_semantics.py @@ -385,10 +385,10 @@ def test_non_map_cpp_remains_exact_baseline_bytes() -> None: observed = scalar ''' cpp = transpile(source) - # Whole-output pin includes the generic run-lifecycle reset. The ordinary - # non-map lowering and its direct value checkpoint remain unchanged. + # Whole-output pin includes the generated source-host constructor and + # lifecycle reset. The ordinary non-map lowering remains unchanged. assert sha256(cpp.encode()).hexdigest() == ( - "113d27f98bebd9ead384d6e9fe72f753c6eee69dde1fb98f1230ead7f4d12f4c" + "1d822b51179dfff05d5b1ecc9b2bfdb7ae2e33b2d497463b96c9f4d6b9b30a38" ) assert '#include ' not in cpp assert "_PFCheckpointTraits" not in cpp diff --git a/tests/test_runtime_var_initialization.py b/tests/test_runtime_var_initialization.py index 142e2d7..bc42880 100644 --- a/tests/test_runtime_var_initialization.py +++ b/tests/test_runtime_var_initialization.py @@ -70,7 +70,7 @@ def test_runtime_scalar_dependencies_follow_source_order(): assert "fromInput = length;" in cpp assert "directInput = get_input_double(\"Direct Seed\", 4.5);" in cpp - on_bar = cpp[cpp.index(" void on_bar("):] + on_bar = cpp[cpp.index(" void on_source_bar("):] input_pos = on_bar.index('length = get_input_int("Length", 3);') ema_pos = on_bar.index("emaValue =") plain_pos = on_bar.index("plainValue =") @@ -98,7 +98,7 @@ def test_conditional_sibling_vars_get_distinct_lazy_members_and_flags(): assert "bool _pf_var_init_pending__blk1 = false;" in cpp assert "pending = current_bar_.low;" in cpp assert "pending__blk1 = current_bar_.high;" in cpp - on_bar = cpp[cpp.index(" void on_bar("):] + on_bar = cpp[cpp.index(" void on_source_bar("):] assert on_bar.index("if (!_pf_var_init_pending)") > on_bar.index("if (([&]") diff --git a/tests/test_source_host_config_codegen.py b/tests/test_source_host_config_codegen.py new file mode 100644 index 0000000..eca517c --- /dev/null +++ b/tests/test_source_host_config_codegen.py @@ -0,0 +1,192 @@ +"""Source-host configuration, overrides, and dynamic risk setter emission.""" + +from __future__ import annotations + +import re + +from pineforge_codegen import transpile + + +_CONFIG_SOURCE = '''//@version=6 +strategy("source host config", process_orders_on_close=true, + calc_on_order_fills=true, initial_capital=123.0, + default_qty_type=strategy.cash, default_qty_value=2.0, pyramiding=3, + commission_type=strategy.commission.cash_per_contract, + commission_value=4.0, slippage=5, margin_long=6.0, margin_short=7.0, + close_entries_rule="ANY") +src = input.source(close, "source") +''' + + +def _constructor(cpp: str) -> str: + start = cpp.index(" explicit GeneratedStrategy()") + end = cpp.index(" void set_strategy_override", start) + return cpp[start:end] + + +def _override(cpp: str) -> str: + start = cpp.index(" void set_strategy_override") + end = cpp.index("\n#ifndef PINEFORGE_HAS_SCRIPT_RUN_PREPARE_V1", start) + return cpp[start:end] + + +def test_constructor_configures_the_source_host_once_in_legacy_write_order(): + constructor = _constructor(transpile(_CONFIG_SOURCE)) + expected = [ + "pineforge::source::PineStrategyConfig cfg{};", + "cfg.process_orders_on_close = true;", + "cfg.calc_on_order_fills = true;", + "cfg.initial_capital = 123.0;", + "cfg.default_qty_type = static_cast(QtyType::CASH);", + "cfg.default_qty_value = 2.0;", + "cfg.pyramiding = 3;", + "cfg.commission_type = static_cast(CommissionType::CASH_PER_CONTRACT);", + "cfg.commission_value = 4.0;", + "cfg.slippage = 5;", + "cfg.margin_long = 6.0;", + "cfg.margin_short = 7.0;", + "cfg.close_entries_rule_any = true;", + "cfg.src_series_active = true;", + "configure_pine_strategy(cfg);", + ] + assert [constructor.index(line) for line in expected] == sorted( + constructor.index(line) for line in expected + ) + assert constructor.count("configure_pine_strategy(cfg);") == 1 + + for member in ( + "process_orders_on_close_", + "calc_on_order_fills_", + "initial_capital_", + "default_qty_type_", + "default_qty_value_", + "pyramiding_", + "commission_type_", + "commission_value_", + "slippage_", + "margin_long_", + "margin_short_", + "close_entries_rule_any_", + "_src_series_active_", + ): + assert f"{member} =" not in constructor + + +def test_runtime_overrides_use_the_source_host_adapter_entry_for_all_keys(): + override = _override(transpile(_CONFIG_SOURCE)) + assert re.findall(r'key == "([^"]+)"', override) == [ + "initial_capital", + "commission_value", + "default_qty_value", + "pyramiding", + "slippage", + "process_orders_on_close", + "calc_on_order_fills", + "close_entries_rule", + "default_qty_type", + "commission_type", + ] + for field in ( + "initial_capital", + "commission_value", + "default_qty_value", + "pyramiding", + "slippage", + "process_orders_on_close", + "calc_on_order_fills", + "close_entries_rule", + "default_qty_type", + "commission_type", + ): + assert f"overrides.{field} =" in override + assert override.count( + "pineforge::source::PineStrategyHost::set_strategy_override(overrides);" + ) == 1 + + for member in ( + "initial_capital_", + "commission_value_", + "default_qty_value_", + "pyramiding_", + "slippage_", + "process_orders_on_close_", + "calc_on_order_fills_", + "close_entries_rule_any_", + "default_qty_type_", + "commission_type_", + ): + assert f"{member} =" not in override + + +def test_risk_calls_use_explicit_source_host_setters_without_member_writes(): + cpp = transpile('''//@version=6 +strategy("source host risk") +strategy.risk.allow_entry_in(strategy.direction.long) +strategy.risk.max_cons_loss_days(2) +strategy.risk.max_drawdown(3.0, strategy.percent_of_equity) +strategy.risk.max_drawdown(4.0, strategy.cash) +strategy.risk.max_intraday_loss(5.0, strategy.percent_of_equity) +strategy.risk.max_intraday_filled_orders(6) +strategy.risk.max_position_size(7.0) +''') + calls = [ + line.strip() for line in cpp.splitlines() if "set_pine_risk_" in line + ] + assert calls == [ + "set_pine_risk_direction(1);", + "set_pine_risk_max_cons_loss_days((int)(2));", + "set_pine_risk_max_drawdown((double)(3.0), true);", + "set_pine_risk_max_drawdown((double)(4.0), false);", + "set_pine_risk_max_intraday_loss((double)(5.0), true);", + "set_pine_risk_max_intraday_filled_orders((int)(6));", + "set_pine_risk_max_position_size((double)(7.0));", + ] + for member in ( + "risk_direction_", + "risk_max_cons_loss_days_", + "risk_max_drawdown_", + "risk_max_drawdown_is_pct_", + "risk_max_intraday_loss_", + "risk_max_intraday_loss_is_pct_", + "max_intraday_filled_orders_", + "risk_max_position_size_", + ): + assert f"{member} =" not in cpp + + +def test_risk_direction_uses_the_pine_signed_encoding_for_all_values(): + cpp = transpile('''//@version=6 +strategy("source host direction encoding") +strategy.risk.allow_entry_in(strategy.direction.short) +strategy.risk.allow_entry_in(strategy.direction.long) +strategy.risk.allow_entry_in(strategy.direction.all) +''') + + calls = [ + line.strip() for line in cpp.splitlines() if "set_pine_risk_direction" in line + ] + assert calls == [ + "set_pine_risk_direction(-1);", + "set_pine_risk_direction(1);", + "set_pine_risk_direction(0);", + ] + + +def test_risk_percent_flags_are_true_only_for_percent_of_equity(): + cpp = transpile('''//@version=6 +strategy("source host percent flags") +strategy.risk.max_drawdown(10, strategy.percent_of_equity) +strategy.risk.max_drawdown(500, strategy.cash) +strategy.risk.max_intraday_loss(10, strategy.percent_of_equity) +strategy.risk.max_intraday_loss(500, strategy.cash) +''') + + calls = [ + line.strip() for line in cpp.splitlines() if "set_pine_risk_max_" in line + ] + assert calls == [ + "set_pine_risk_max_drawdown((double)(10), true);", + "set_pine_risk_max_drawdown((double)(500), false);", + "set_pine_risk_max_intraday_loss((double)(10), true);", + "set_pine_risk_max_intraday_loss((double)(500), false);", + ] diff --git a/tests/test_source_host_risk_runtime.py b/tests/test_source_host_risk_runtime.py new file mode 100644 index 0000000..577c71e --- /dev/null +++ b/tests/test_source_host_risk_runtime.py @@ -0,0 +1,85 @@ +"""Runtime pins for generated source-host risk setter behaviour. + +The text tests in :mod:`tests.test_source_host_config_codegen` establish the +Pine-to-host argument encoding. These probes compile and run generated C++ +against the engine so the host's interpretation of those arguments remains +observable at the strategy boundary. +""" + +from __future__ import annotations + +from pineforge_codegen import transpile +from tests.test_runtime_var_initialization import _compile_and_run + + +_SHORT_ONLY_SOURCE = '''//@version=6 +strategy("short-only risk direction", initial_capital=1000, + default_qty_type=strategy.fixed, default_qty_value=1) +strategy.risk.allow_entry_in(strategy.direction.short) +if bar_index == 0 + strategy.entry("long", strategy.long) +''' + + +def test_generated_short_only_direction_drops_a_long_entry() -> None: + cpp = transpile(_SHORT_ONLY_SOURCE) + driver = r''' +#include + +int main() { + Bar bars[] = { + Bar{100.0, 100.0, 100.0, 100.0, 1.0, 60000}, + Bar{100.0, 100.0, 100.0, 100.0, 1.0, 120000}, + Bar{100.0, 100.0, 100.0, 100.0, 1.0, 180000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 3); + if (!strategy.last_error().empty()) return 2; + if (strategy.live_position_size() != 0.0) return 3; + if (strategy.trade_count() != 0) return 4; + std::cout << "short-direction-ok\n"; +} +''' + assert _compile_and_run(cpp + driver) == "short-direction-ok\n" + + +_STICKY_PERCENT_SOURCE = '''//@version=6 +strategy("sticky max drawdown percent flag", initial_capital=1000, + default_qty_type=strategy.fixed, default_qty_value=10) +strategy.risk.max_drawdown(10, strategy.percent_of_equity) +strategy.risk.max_drawdown(500, strategy.cash) +if bar_index == 0 + strategy.entry("loss", strategy.long) +if bar_index == 1 + strategy.close("loss") +if bar_index == 3 + strategy.entry("after", strategy.long) +''' + + +def test_generated_percent_drawdown_flag_stays_sticky_after_cash_call() -> None: + cpp = transpile(_STICKY_PERCENT_SOURCE) + driver = r''' +#include + +int main() { + // The first entry fills at 100, the deferred close fills at 40, and its + // 600-cash loss is above the later 500 value. If that later value clears + // the percent flag, the risk halt drops the bar-three entry. A sticky + // percent flag instead interprets 500 as 500% of peak equity, allowing it. + Bar bars[] = { + Bar{100.0, 100.0, 100.0, 100.0, 1.0, 60000}, + Bar{100.0, 100.0, 100.0, 100.0, 1.0, 120000}, + Bar{40.0, 40.0, 40.0, 40.0, 1.0, 180000}, + Bar{40.0, 40.0, 40.0, 40.0, 1.0, 240000}, + Bar{40.0, 40.0, 40.0, 40.0, 1.0, 300000}, + }; + GeneratedStrategy strategy; + strategy.run(bars, 5); + if (!strategy.last_error().empty()) return 2; + if (strategy.trade_count() != 1) return 3; + if (strategy.live_position_size() != 10.0) return 4; + std::cout << "sticky-percent-ok\n"; +} +''' + assert _compile_and_run(cpp + driver) == "sticky-percent-ok\n" diff --git a/tests/test_transpile_pf_trace.py b/tests/test_transpile_pf_trace.py index b481452..1b03e08 100644 --- a/tests/test_transpile_pf_trace.py +++ b/tests/test_transpile_pf_trace.py @@ -2,7 +2,7 @@ The pragma adds a per-bar instrumentation hook: each occurrence binds a label to a Pine expression and the codegen emits, at the bottom of every -``on_bar()``, a ``trace(label, value)`` call wrapped in +``on_source_bar()``, a ``trace(label, value)`` call wrapped in ``if (trace_enabled_) { ... }`` so cost is zero when tracing is off. The engine API (``trace`` overloads + ``trace_enabled_`` flag) is owned by a parallel runtime patch — these tests therefore assert only on the @@ -26,11 +26,11 @@ def _on_bar_body(cpp: str) -> str: - """Slice the generated C++ between the ``on_bar(...)`` opener and the + """Slice the generated C++ between the ``on_source_bar(...)`` opener and the matching closing ``}``. Used so assertions about ordering / scoping aren't fooled by other top-level emissions in the file.""" - m = re.search(r"void on_bar\(const Bar& bar\) override \{(.*?)\n \}", cpp, re.DOTALL) - assert m is not None, f"could not locate on_bar() in generated C++:\n{cpp[:600]}" + m = re.search(r"void on_source_bar\(const Bar& bar\) override \{(.*?)\n \}", cpp, re.DOTALL) + assert m is not None, f"could not locate on_source_bar() in generated C++:\n{cpp[:600]}" return m.group(1) diff --git a/tests/test_vwap_property_source.py b/tests/test_vwap_property_source.py index 79901a5..1275967 100644 --- a/tests/test_vwap_property_source.py +++ b/tests/test_vwap_property_source.py @@ -59,7 +59,7 @@ def test_property_and_explicit_hlc3_call_agree(): def test_two_bare_reads_are_two_sites_each_advanced_once(): cpp = transpile(_pine("v = ta.vwap\nw = ta.vwap")) - body = cpp[cpp.index("void on_bar("):] + body = cpp[cpp.index("void on_source_bar("):] body = body[: body.index("void precalculate(")] if "void precalculate(" in body else body assert len([c for c in _calls(body, "_ta_vwap_1.compute")]) == 1, body assert len([c for c in _calls(body, "_ta_vwap_2.compute")]) == 1, body @@ -124,4 +124,3 @@ def test_user_declared_vwap_variable_is_not_the_property(): assert "ta::VWAP" not in cpp, cpp assert "PF_VWAP_SESSION_ANCHOR_ARGS" not in cpp, cpp assert "vwap" in cpp # the user's own variable is what the read resolves to -