Skip to content
Merged
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ pineforge_codegen/
│ enum is too coarse for.
├── tv_number_format.py TU-local numeric formatter emitted for
│ str.tostring / str.format / log.*.
├── session_market.py session.ismarket of a chart bar: the
│ engine's session calendar asked at the
│ bar's open time, cached per strategy.
├── input.py input.* / `input()` lowering.
└── helpers.py CPP_RESERVED + small text helpers.
tests/
Expand Down Expand Up @@ -264,6 +267,7 @@ taxonomy:
| Color na conversions | Packed colors use `int64_t` storage, including typed scalar, array and matrix paths, so their `na<int64_t>()` sentinel survives. `color.new` preserves an `na` base; `na` transparency becomes 100; an `na` `color.rgb` channel becomes zero. The covered TradingView color probes are pinned by `tests/test_na_truthiness.py`. |
| Collection history warning | `array`/`map` `id[k]` still lowers to current collection element access because the engine has no per-bar collection ID history. Codegen warns on every such read and keeps the existing lowering; a missing index is not represented faithfully. |
| syminfo na-gap warning | `SUPPORTED_SYMINFO` = every `SYMINFO_MEMBER_MAP` key, but members whose emission is `na<T>()` or a `get_syminfo_metadata(...)` lookup (root/pricescale/minmove/mincontract/current_contract/expiration_date/isin/sector/industry + fundamentals/recommendations/target_price_*) form `_SYMINFO_SILENT_GAP_FIELDS` (derived from the emission table, so new na-accept fields can't drift out): every read WARNS that the value is na until a data feed injects it. |
| Session flags | A chart bar's `session.ismarket` asks the engine's session calendar (`native_calendar::session_day_at`, on the calendar the kernel builds from the same session and timezone) whether it holds the bar's open time; a D/W/M bar is in market. TradingView flags a bar by its open time (on extended-hours NASDAQ:AAPL 60 the 09:00 bar, which holds the 09:30 open, is pre-market and the 16:00 bar post-market), reads a day mask per session day, and flags OANDA:XAUUSD's daily bars, stamped at the 17:00 ET break, in market. The emitted `_PFSessionMarket` type and its per-strategy `_pf_session_market_` cache (`codegen/session_market.py`, outside the checkpointed script state) replaced the time-of-day predicate, which missed every Sunday-evening open under a `:23456` mask and every `0000-2400` bar (the calendar also puts a masked overnight session's bars after the weekly close out of market); the kernel's per-bar `session_ismarket_` asks the bar's grid interval and puts that 16:00 bar in market. A bar in market is in neither `session.ispremarket` nor `session.ispostmarket`; off it, the windows from 04:00 to the first window's open and from its close to 20:00 decide (the predicates read 98-117 in-market bars of each ES1! / XAUUSD tape as pre-market). A `request.security` payload's own session reads keep the time-of-day predicates (the security bar's open time on an intraday chart, a constant on a D/W/M chart) and WARN; a user function the evaluator calls instead reads the chart bar. `session.isfirstbar` / `islastbar` (and `_regular`) read the kernel's session-day facts, unchanged; on the extended-hours tape they differ from TradingView (one session string, the grid interval), as the test pins. `tests/test_e2e_session_ismarket.py` replays 15 TradingView tapes (`tests/fixtures/session_ismarket`). |


When extending codegen with a new Pine builtin: add to the corresponding
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ pineforge_codegen/
│ enum is too coarse for.
├── tv_number_format.py TU-local numeric formatter emitted for
│ str.tostring / str.format / log.*.
├── session_market.py session.ismarket of a chart bar: the
│ engine's session calendar asked at the
│ bar's open time, cached per strategy.
├── input.py input.* / `input()` lowering.
└── helpers.py CPP_RESERVED + small text helpers.
tests/
Expand Down Expand Up @@ -264,6 +267,7 @@ taxonomy:
| Color na conversions | Packed colors use `int64_t` storage, including typed scalar, array and matrix paths, so their `na<int64_t>()` sentinel survives. `color.new` preserves an `na` base; `na` transparency becomes 100; an `na` `color.rgb` channel becomes zero. The covered TradingView color probes are pinned by `tests/test_na_truthiness.py`. |
| Collection history warning | `array`/`map` `id[k]` still lowers to current collection element access because the engine has no per-bar collection ID history. Codegen warns on every such read and keeps the existing lowering; a missing index is not represented faithfully. |
| syminfo na-gap warning | `SUPPORTED_SYMINFO` = every `SYMINFO_MEMBER_MAP` key, but members whose emission is `na<T>()` or a `get_syminfo_metadata(...)` lookup (root/pricescale/minmove/mincontract/current_contract/expiration_date/isin/sector/industry + fundamentals/recommendations/target_price_*) form `_SYMINFO_SILENT_GAP_FIELDS` (derived from the emission table, so new na-accept fields can't drift out): every read WARNS that the value is na until a data feed injects it. |
| Session flags | A chart bar's `session.ismarket` asks the engine's session calendar (`native_calendar::session_day_at`, on the calendar the kernel builds from the same session and timezone) whether it holds the bar's open time; a D/W/M bar is in market. TradingView flags a bar by its open time (on extended-hours NASDAQ:AAPL 60 the 09:00 bar, which holds the 09:30 open, is pre-market and the 16:00 bar post-market), reads a day mask per session day, and flags OANDA:XAUUSD's daily bars, stamped at the 17:00 ET break, in market. The emitted `_PFSessionMarket` type and its per-strategy `_pf_session_market_` cache (`codegen/session_market.py`, outside the checkpointed script state) replaced the time-of-day predicate, which missed every Sunday-evening open under a `:23456` mask and every `0000-2400` bar (the calendar also puts a masked overnight session's bars after the weekly close out of market); the kernel's per-bar `session_ismarket_` asks the bar's grid interval and puts that 16:00 bar in market. A bar in market is in neither `session.ispremarket` nor `session.ispostmarket`; off it, the windows from 04:00 to the first window's open and from its close to 20:00 decide (the predicates read 98-117 in-market bars of each ES1! / XAUUSD tape as pre-market). A `request.security` payload's own session reads keep the time-of-day predicates (the security bar's open time on an intraday chart, a constant on a D/W/M chart) and WARN; a user function the evaluator calls instead reads the chart bar. `session.isfirstbar` / `islastbar` (and `_regular`) read the kernel's session-day facts, unchanged; on the extended-hours tape they differ from TradingView (one session string, the grid interval), as the test pins. `tests/test_e2e_session_ismarket.py` replays 15 TradingView tapes (`tests/fixtures/session_ismarket`). |


When extending codegen with a new Pine builtin: add to the corresponding
Expand Down
19 changes: 19 additions & 0 deletions pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class _StableVarCtorLiteral:
# small naming/walk utilities can be shared with future visitor mixins.
from .helpers import CPP_RESERVED, NamingHelper, na_preserving_int_cast, pine_truth_cast
from .constant_fold import fold_numeric_expression
from .session_market import SESSION_MARKET_CPP, SESSION_MARKET_MEMBER

# TypeInferer mixin owns the ~15 type-spec / C++-type inference helpers
# previously scattered across this module; see ``codegen/types.py``.
Expand Down Expand Up @@ -319,6 +320,15 @@ def func_var_storage(owner: str, raw_name: str) -> str:
# Set of var/series member names that belong to user functions (need cloning)
self._func_var_members_set: set[str] = set()
self._precalc_loop_active: bool = False
# Nonzero while the expression visitor lowers a request.security
# payload (``_build_security_expr``): the session helper below reads
# the chart's timeframe, which does not describe the security bar.
self._security_payload_depth: int = 0
# Set when a chart expression calls ``_pf_session_market_``; its type
# and member are emitted once the whole TU is lowered.
self._uses_session_market: bool = False
# session.* reads inside a request.security payload already warned.
self._warned_security_session_sites: set[int] = set()
# Top-level lazy-edge TA sites hoisted to every-bar evaluation for the
# statement currently being lowered: FuncCall id -> local name, and
# Subscript id -> ``_hist_call_*`` member (see ``ta.py``).
Expand Down Expand Up @@ -4114,6 +4124,9 @@ def generate(self) -> str:
# per-callsite instances are declared below inside GeneratedStrategy
# and therefore join the automatic COOF checkpoint inventory.
self._emit_lazy_source_clock_helper(lines)
# The chart-bar session type, inserted here once the class is lowered
# and known to call it (codegen/session_market.py).
_session_market_at = len(lines)

# 2. Open class
lines.append("class GeneratedStrategy : public pineforge::source::PineStrategyHost {")
Expand Down Expand Up @@ -4654,6 +4667,8 @@ def allocate_legacy_flag(base: str) -> str:
)
self._emit_script_state_hooks(lines, _script_state_members)
lines.append("")
# Its per-strategy cache, outside the checkpointed script state.
_session_market_member_at = len(lines)

# 9. Constructor with TA initializer list
self._emit_constructor(lines)
Expand Down Expand Up @@ -4721,6 +4736,10 @@ def allocate_legacy_flag(base: str) -> str:
# 13. extern "C" interface
self._emit_extern_c(lines)

if self._uses_session_market:
lines.insert(_session_market_member_at, SESSION_MARKET_MEMBER)
lines.insert(_session_market_at, SESSION_MARKET_CPP)

return "\n".join(lines)

# ------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion pineforge_codegen/codegen/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ def color_alpha_cast(value_cpp: str) -> str:
live_position_size pending_order_count market_admission_journal
pine_time pine_time_close pine_time_tradingday pine_random
pine_runtime_error pine_enum_str_at pine_session_ismarket
pine_session_ispostmarket pine_session_ispremarket
pine_session_ispostmarket pine_session_ispremarket _PFSessionMarket
_pf_session_market_ script_tf_
tf_change tf_is_daily tf_is_intraday tf_is_monthly tf_is_seconds
tf_is_weekly tf_multiplier tf_to_seconds time_close timestamp main_period
round_to_mintick calc_qty
Expand Down
9 changes: 8 additions & 1 deletion pineforge_codegen/codegen/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
identity → index in ``ctx.ta_call_sites``.
- ``self._func_names`` (``set[str]``): user-defined function names.
- ``self._func_info_map`` (``dict[str, FuncInfo]``): name -> FuncInfo.
- ``self._security_payload_depth`` (``int``): raised around the
``_visit_expr`` fallback of ``_build_security_expr``; the expression
visitor keeps the session.* time-of-day predicates while it is nonzero.

Sibling-mixin methods consumed via ``self``:

Expand Down Expand Up @@ -3828,5 +3831,9 @@ def _build_security_expr(
)
return f"(security_series_slot_is_new({sec_id}) ? {sec_name}.compute({compute_args}) : {sec_name}.recompute({compute_args}))"

result = self._visit_expr(expr_node)
self._security_payload_depth += 1
try:
result = self._visit_expr(expr_node)
finally:
self._security_payload_depth -= 1
return self._rewrite_security_cpp(result, sec_id, security_mutable_names, helper_binding_stack)
63 changes: 63 additions & 0 deletions pineforge_codegen/codegen/session_market.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""C++ emitted for a chart bar's session.ismarket.

TradingView flags a bar by its own open time: on an extended-hours
NASDAQ:AAPL 60 chart the 09:00 bar, which holds the 09:30 open, is
pre-market and the 16:00 bar post-market. It reads a session's day mask per
session day, so an overnight session's Sunday-evening open belongs to
Monday's, and a ``0000-2400`` day is in market throughout; a D/W/M bar holds
whole session days (``tests/test_e2e_session_ismarket.py`` replays the tapes).

The engine's session calendar answers that instant: ``native_calendar``'s
session day at the bar's open, on the calendar the kernel builds for the run
from the same session and timezone (the adapter reads an empty session as
``24x7`` and an empty timezone as ``UTC``), resolved once per session day and
read as the kernel reads it (a day the calendar cannot resolve is out of
session). The time-of-day predicate ``pine_session_*`` tests the instant's own
weekday and does not parse ``2400``; the kernel's per-bar fact
``session_ismarket_`` asks the bar's grid interval, which a 16:00 bar after a
09:30-16:00 session shares with 15:30.

``SESSION_MARKET_CPP`` precedes the strategy class; each strategy holds one
``SESSION_MARKET_MEMBER`` after its checkpointed script state (a cache of an
answer that depends on the session, the timezone and the instant only). A
session the calendar cannot parse, which the kernel would not run, keeps the
time-of-day predicate.
"""

SESSION_MARKET_CPP = r"""
struct _PFSessionMarket {
std::string session_key;
std::string tz_key;
bool parsed = false;
std::optional<native_calendar::SessionCalendar> calendar;
std::optional<native_calendar::NativeSessionDay> day;

bool operator()(const std::string& session, const std::string& tz,
const std::string& chart_tf, int64_t bar_ms) {
if (tf_is_daily_or_higher(chart_tf)) return true;
if (!parsed || session_key != session || tz_key != tz) {
parsed = false;
day.reset();
calendar = native_calendar::parse_session(
session.empty() ? std::string_view("24x7") : std::string_view(session),
tz.empty() ? std::string_view("UTC") : std::string_view(tz));
session_key = session;
tz_key = tz;
parsed = true;
}
if (!calendar) return pine_session_ismarket(session, tz, bar_ms);
if (day && day->holds(bar_ms)) return day->in_session_at(bar_ms);
try {
auto found = native_calendar::session_day_at(*calendar, bar_ms);
if (!found) return false;
const bool in_market = found->in_session_at(bar_ms);
if (found->holds(bar_ms)) day = std::move(found);
return in_market;
} catch (...) {
return false;
}
}
};
"""

SESSION_MARKET_MEMBER = " mutable _PFSessionMarket _pf_session_market_;"
56 changes: 46 additions & 10 deletions pineforge_codegen/codegen/visit_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -851,16 +851,52 @@ def _visit_member_access(self, node: MemberAccess) -> str:
return 'std::string("regular")'
if node.member == "extended":
return 'std::string("extended")'
# session.is* predicates backed by engine session_ismarket_ etc.
# session.isfirstbar_regular / islastbar_regular are aliased to
# their non-_regular counterparts (engine has one session string;
# see session_time.hpp limitation comment).
if node.member == "ismarket":
return "pine_session_ismarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
if node.member == "ispremarket":
return "pine_session_ispremarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
if node.member == "ispostmarket":
return "pine_session_ispostmarket(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
# session.isfirstbar / islastbar read the kernel's session-day
# facts; session.isfirstbar_regular / islastbar_regular are
# aliased to their non-_regular counterparts (engine has one
# session string; see session_time.hpp limitation comment).
if node.member in ("ismarket", "ispremarket", "ispostmarket"):
# A chart bar is in market when the engine's session
# calendar holds its open time (codegen/session_market.py).
# The time-of-day predicates test each instant's own
# weekday and window: ismarket missed the Sunday-evening
# open of a ":23456" session and every "0000-2400" bar,
# and ispremarket / ispostmarket held on in-market bars of
# an overnight session, where TradingView's tapes flag
# every bar in market and none in an extended session
# (tests/test_e2e_session_ismarket.py). A bar in market is
# in neither extended session; off it, the windows decide.
# A request.security payload runs on its own bars, whose
# timeframe the helper does not know, so it keeps the
# predicates at the security bar's time, with a warning.
args = "(syminfo_.session, syminfo_.timezone, current_bar_.timestamp)"
predicate = f"pine_session_{node.member}{args}"
if self._security_payload_depth:
if id(node) not in self._warned_security_session_sites:
self._warned_security_session_sites.add(id(node))
extended = ("" if node.member == "ismarket" else
", and an in-market bar of an overnight "
"session can read as pre- or post-market")
constant = ("always true" if node.member == "ismarket"
else "always false")
self._codegen_warning(
node,
f"session.{node.member} inside request.security keeps "
"the time-of-day predicate, which can differ from "
"TradingView: on an intraday chart it tests the "
"security bar's open time, a session's day mask on "
"that instant's own weekday, and does not read "
f"\"2400\"{extended}; on a D/W/M chart it is "
f"{constant}. The chart's own session flags ask the "
"engine's session calendar.",
)
return predicate
self._uses_session_market = True
ismarket = ("_pf_session_market_(syminfo_.session, syminfo_.timezone, "
"script_tf_, current_bar_.timestamp)")
if node.member == "ismarket":
return ismarket
return f"(!{ismarket} && {predicate})"
if node.member in ("isfirstbar", "isfirstbar_regular"):
return "session_isfirstbar_"
if node.member in ("islastbar", "islastbar_regular"):
Expand Down
Loading
Loading