Skip to content

Commit e0a452b

Browse files
authored
feat(finance): compare explicit stock instants without deriving flows (#6246)
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
1 parent c2044e3 commit e0a452b

10 files changed

Lines changed: 548 additions & 50 deletions

File tree

‎packages/loopx-finance-value-discovery/CONTRACT.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,65 @@ authentication, lifecycle/PIT, financial admission and trading remain false.
585585
Existing period and cash operations are unchanged; old providers reject this
586586
new schema. App/Lark interaction, source-consumer adoption and financial utility
587587
are independent remaining qualifications.
588+
589+
# Stock instant change (extension 0.8.12)
590+
591+
`finance_stock_change_input_v1` explicitly compares stocks. The root contains
592+
exactly `schema_version`, `later`, `earlier`, and `compatibility`. Each operand
593+
contains `source_digest`, original nullable `context`, nullable `point`, the
594+
existing nullable eight-field `statement_basis`, `aggregation_kind`, and
595+
`accuracy`. The shared temporal vocabulary is unchanged; only `stock` qualifies.
596+
597+
A point has exactly `context_source_digest`, `evidence_ref`, `role`, and
598+
`instant`. References/digests retain the existing period owner's bounds.
599+
`instant` is null or an explicit dateTime; the economic role is an exact token.
600+
The original context must encode an instant with a known timezone, and its
601+
normalized boundary must equal the declared point. XBRL date-only end-of-day
602+
conversion and offsets reuse `assess_period_encoding`; column labels cannot
603+
supply absent original context or timezone. The earlier point must be strictly
604+
before the later one; equal or reversed instants refuse a qualified change.
605+
These are balance instants, not new first-public or observation clocks.
606+
607+
Subjects, declared metrics and currency/scale units must match. Distinct column
608+
scopes can belong to one source/filing/version. Other source/version joins need
609+
explicit `compatibility` with exactly `evidence_ref`, `later`, and `earlier`;
610+
each pin contains the exact current `source_digest`, `filing_ref`, `version_ref`,
611+
and `scope_ref`. This reuses the cumulative-flow version-compatibility owner.
612+
Neither matching names nor matching numbers proves scope comparability or
613+
completeness. A producer owns those meanings, withdrawals and conflicts.
614+
615+
The v1 requires producer-declared exact accuracy for both amounts. Finite or
616+
missing precision remains visible and holds the qualified change. There is no
617+
implicit rounding policy, token-quantity unit conversion or inferred cash
618+
identity. Unknown unit does not become USD, and a composite metric does not
619+
become pure cash. Different metrics/subjects/units and nonstocks suppress even
620+
the diagnostic candidate.
621+
622+
`finance_stock_change_assessment_v1` retains original operands, point and
623+
accuracy assessments, current compatibility pins, canonical input digest and
624+
sorted reasons. `candidate_lexical_difference` is diagnostic bounded Decimal
625+
subtraction when the declared stock identities and amounts are known.
626+
`stock_change` and `change_window` are emitted only when
627+
`change_evidence_eligible` is true. A window records two instants, not a duration
628+
flow. `cash_flow_derived` and `causes_of_change_assessed` are always false:
629+
stock change cannot establish acquisitions, financing, spending or its causes.
630+
Source authentication, lifecycle/PIT, financial admission and trading also
631+
remain false. Upstream source admission remains independent.
632+
633+
```sh
634+
loopx-finance-value-discovery assess-stock-change \
635+
--input-json packages/loopx-finance-value-discovery/examples/stock-change-v1.json
636+
```
637+
638+
The same schema selects this operation on the installed provider's stdin
639+
runtime. Missing evidence is a successful assessment with a held result;
640+
malformed closed shapes use the existing error packet/nonzero exit. Optional
641+
provider lifecycle remains unchanged. Disable through `loopx extension disable
642+
loopx-finance-value-discovery --execute`; rollback uses the prior provider and
643+
its original input versions. Old providers reject the new schema, while old
644+
cash, duration comparison v1/v2, and flow difference v1 inputs keep their
645+
original behavior and replay clocks. No new catalog or Core authority exists.
646+
647+
This stage delivers CLI/runtime computation. Its App/Lark interaction and the
648+
source provider's production consumer are still companion qualifications;
649+
installation and this calculation do not establish financial utility.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"schema_version": "finance_stock_change_input_v1",
3+
"compatibility": null,
4+
"later": {
5+
"source_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
6+
"context": {
7+
"instant": "2002-12-31Z"
8+
},
9+
"point": {
10+
"context_source_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
11+
"evidence_ref": "synthetic:point:later",
12+
"role": "balance_sheet",
13+
"instant": "2003-01-01T00:00:00Z"
14+
},
15+
"statement_basis": {
16+
"subject_ref": "synthetic:issuer",
17+
"metric_ref": "cash_and_equivalents",
18+
"filing_ref": "synthetic:filing",
19+
"version_ref": "synthetic:v1",
20+
"scope_ref": "synthetic:column:later",
21+
"evidence_ref": "synthetic:row:later",
22+
"unit": {
23+
"currency": "USD",
24+
"scale": 6
25+
},
26+
"value": "123.50"
27+
},
28+
"aggregation_kind": "stock",
29+
"accuracy": {
30+
"decimals": "INF"
31+
}
32+
},
33+
"earlier": {
34+
"source_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
35+
"context": {
36+
"instant": "2001-12-31Z"
37+
},
38+
"point": {
39+
"context_source_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
40+
"evidence_ref": "synthetic:point:earlier",
41+
"role": "balance_sheet",
42+
"instant": "2002-01-01T00:00:00Z"
43+
},
44+
"statement_basis": {
45+
"subject_ref": "synthetic:issuer",
46+
"metric_ref": "cash_and_equivalents",
47+
"filing_ref": "synthetic:filing",
48+
"version_ref": "synthetic:v1",
49+
"scope_ref": "synthetic:column:earlier",
50+
"evidence_ref": "synthetic:row:earlier",
51+
"unit": {
52+
"currency": "USD",
53+
"scale": 6
54+
},
55+
"value": "100.25"
56+
},
57+
"aggregation_kind": "stock",
58+
"accuracy": {
59+
"decimals": "INF"
60+
}
61+
}
62+
}

‎packages/loopx-finance-value-discovery/extension.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
schema_version = "loopx_extension_manifest_v0"
22
id = "loopx-finance-value-discovery"
3-
version = "0.8.11"
3+
version = "0.8.12"
44
requires_loopx_api = ">=1,<2"
55
permissions = []
66

‎packages/loopx-finance-value-discovery/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "loopx-finance-value-discovery"
7-
version = "0.8.11"
7+
version = "0.8.12"
88
description = "Public-safe finance value-discovery extension for LoopX."
99
readme = "README.md"
1010
requires-python = ">=3.11"

‎packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
from importlib import import_module
44

5+
from .stock_change import (
6+
FINANCE_STOCK_CHANGE_INPUT_SCHEMA_VERSION,
7+
FINANCE_STOCK_CHANGE_SCHEMA_VERSION,
8+
assess_stock_change,
9+
)
10+
511
from .cumulative_flow import (
612
FINANCE_FLOW_DIFFERENCE_INPUT_SCHEMA_VERSION,
713
FINANCE_FLOW_DIFFERENCE_SCHEMA_VERSION,
@@ -87,6 +93,8 @@
8793
"FINANCE_CASH_RECONCILIATION_SCHEMA_VERSION",
8894
"FINANCE_CONTRACT_LIQUIDITY_EVALUATION_SCHEMA_VERSION",
8995
"FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION",
96+
"FINANCE_STOCK_CHANGE_INPUT_SCHEMA_VERSION",
97+
"FINANCE_STOCK_CHANGE_SCHEMA_VERSION",
9098
"FINANCE_FLOW_DIFFERENCE_INPUT_SCHEMA_VERSION",
9199
"FINANCE_FLOW_DIFFERENCE_SCHEMA_VERSION",
92100
"FINANCE_METRIC_PACK_EVALUATION_SCHEMA_VERSION",
@@ -111,6 +119,7 @@
111119
"build_finance_transaction_approval_packet",
112120
"build_finance_value_discovery_packet",
113121
"assess_numeric_accuracy",
122+
"assess_stock_change",
114123
"assess_flow_difference",
115124
"assess_cash_reconciliation",
116125
"assess_period_comparison",

‎packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
assess_flow_difference,
5757
)
5858

59+
from .stock_change import (
60+
FINANCE_STOCK_CHANGE_INPUT_SCHEMA_VERSION,
61+
assess_stock_change,
62+
)
63+
5964
FINANCE_RESEARCH_DASHBOARD_INPUT_SCHEMA_VERSION = "finance_research_dashboard_input_v0"
6065

6166

@@ -106,6 +111,11 @@ def _direct_parser() -> argparse.ArgumentParser:
106111
help="Derive a tail flow from compatible cumulative/prefix declarations.",
107112
)
108113
flow_parser.add_argument("--input-json", required=True)
114+
stock_parser = sub.add_parser(
115+
"assess-stock-change",
116+
help="Compare ordered stock instants; never derive a cash flow.",
117+
)
118+
stock_parser.add_argument("--input-json", required=True)
109119
cash_parser = sub.add_parser(
110120
"assess-cash",
111121
help="Reconcile six source-column signed cash amounts and retain unknowns.",
@@ -216,6 +226,8 @@ def run(argv: Sequence[str] | None = None) -> int:
216226
schema_version = payload.get("schema_version")
217227
if schema_version == FINANCE_CASH_RECONCILIATION_INPUT_SCHEMA_VERSION:
218228
packet = assess_cash_reconciliation(payload)
229+
elif schema_version == FINANCE_STOCK_CHANGE_INPUT_SCHEMA_VERSION:
230+
packet = assess_stock_change(payload)
219231
elif schema_version == FINANCE_FLOW_DIFFERENCE_INPUT_SCHEMA_VERSION:
220232
packet = assess_flow_difference(payload)
221233
elif schema_version in {
@@ -256,6 +268,8 @@ def run(argv: Sequence[str] | None = None) -> int:
256268
try:
257269
if args.command == "assess-period":
258270
packet = assess_period_comparison(_load_json(args.input_json))
271+
elif args.command == "assess-stock-change":
272+
packet = assess_stock_change(_load_json(args.input_json))
259273
elif args.command == "assess-period-difference":
260274
packet = assess_flow_difference(_load_json(args.input_json))
261275
elif args.command == "assess-cash":
@@ -326,7 +340,7 @@ def run(argv: Sequence[str] | None = None) -> int:
326340
"replay-beta, evaluate-pack, replay-pack, list-packs, "
327341
"render-lark-card, build-operation-request, or "
328342
"evaluate-contract-liquidity, evaluate-position, assess-period, "
329-
"assess-period-difference, or assess-cash"
343+
"assess-period-difference, assess-stock-change, or assess-cash"
330344
)
331345
except Exception as exc:
332346
# Position inputs are private; malformed values never enter diagnostics.

‎packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cumulative_flow.py‎

Lines changed: 3 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,18 @@
1313
from enum import StrEnum
1414
from typing import Any
1515

16-
from .cash_reconciliation import _encoded, _fields, _number, _text
16+
from .cash_reconciliation import _encoded, _fields, _number
1717
from .numeric_accuracy import NumericAccuracyState
1818
from .period_semantics import (
1919
FINANCE_PERIOD_COMPARISON_INPUT_SCHEMA_VERSION,
2020
assess_period_comparison,
2121
)
2222
from .replay import canonical_sha256
23-
from .statement_basis import _basis
23+
from .statement_basis import _basis, _version_compatibility
2424

2525
FINANCE_FLOW_DIFFERENCE_INPUT_SCHEMA_VERSION = "finance_flow_difference_input_v1"
2626
FINANCE_FLOW_DIFFERENCE_SCHEMA_VERSION = "finance_flow_difference_assessment_v1"
2727
_PERIOD_FIELDS = {"source_digest", "context", "economic_period"}
28-
_PIN_FIELDS = {"source_digest", "filing_ref", "version_ref", "scope_ref"}
2928

3029

3130
class TemporalAggregationKind(StrEnum):
@@ -37,49 +36,6 @@ class TemporalAggregationKind(StrEnum):
3736
RATIO = "ratio"
3837

3938

40-
def _compatibility(
41-
declaration: Any,
42-
operands: Mapping[str, Any],
43-
bases: Mapping[str, Any],
44-
reasons: set[str],
45-
) -> None:
46-
if declaration is None:
47-
# Distinct period columns may share one filing/version. Scope is retained
48-
# in lineage; it is not expected to be the same column on both sides.
49-
total, prefix = bases["total"], bases["prefix"]
50-
if (
51-
total is None
52-
or prefix is None
53-
or any(
54-
total[field] is None or total[field] != prefix[field]
55-
for field in ("filing_ref", "version_ref")
56-
)
57-
or operands["total"]["source_digest"] != operands["prefix"]["source_digest"]
58-
):
59-
reasons.add("source_version_compatibility_missing")
60-
return
61-
declaration = _fields(
62-
declaration, {"evidence_ref", "total", "prefix"}, "flow compatibility"
63-
)
64-
if declaration["evidence_ref"] is None:
65-
reasons.add("compatibility_evidence_missing")
66-
else:
67-
_text(declaration["evidence_ref"], "compatibility evidence_ref")
68-
for side in ("total", "prefix"):
69-
pin = _fields(declaration[side], _PIN_FIELDS, "flow compatibility pin")
70-
for field in _PIN_FIELDS:
71-
if pin[field] is None:
72-
reasons.add(f"{side}_compatibility_{field}_missing")
73-
else:
74-
_text(pin[field], field)
75-
expected = (
76-
operands[side][field]
77-
if field == "source_digest"
78-
else (bases[side][field] if bases[side] is not None else None)
79-
)
80-
if expected is None or pin[field] != expected:
81-
reasons.add(f"{side}_compatibility_{field}_mismatch")
82-
8339

8440
def assess_flow_difference(payload: Mapping[str, Any]) -> dict[str, Any]:
8541
"""Subtract a prefix only after declared flow/identity/period/accuracy gates.
@@ -149,7 +105,7 @@ def assess_flow_difference(payload: Mapping[str, Any]) -> dict[str, Any]:
149105
):
150106
reasons.add(f"flow_{field}_mismatch")
151107
identity_matches = False
152-
_compatibility(payload["compatibility"], operands, bases, reasons)
108+
_version_compatibility(payload["compatibility"], operands, bases, reasons)
153109

154110
tail = None
155111
left, right = period["operands"]["left"], period["operands"]["right"]

‎packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/statement_basis.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,54 @@ def _basis(value: Any, side: str, reasons: set[str]) -> Mapping[str, Any] | None
5454
return basis
5555

5656

57+
58+
def _version_compatibility(
59+
declaration: Any,
60+
operands: Mapping[str, Any],
61+
bases: Mapping[str, Any],
62+
reasons: set[str],
63+
*,
64+
sides: tuple[str, str] = ("total", "prefix"),
65+
) -> None:
66+
if declaration is None:
67+
# Distinct period columns may share one filing/version. Scope is retained
68+
# in lineage; it is not expected to be the same column on both sides.
69+
left, right = sides
70+
total, prefix = bases[left], bases[right]
71+
if (
72+
total is None
73+
or prefix is None
74+
or any(
75+
total[field] is None or total[field] != prefix[field]
76+
for field in ("filing_ref", "version_ref")
77+
)
78+
or operands[left]["source_digest"] != operands[right]["source_digest"]
79+
):
80+
reasons.add("source_version_compatibility_missing")
81+
return
82+
declaration = _fields(
83+
declaration, {"evidence_ref", *sides}, "source compatibility"
84+
)
85+
if declaration["evidence_ref"] is None:
86+
reasons.add("compatibility_evidence_missing")
87+
else:
88+
_text(declaration["evidence_ref"], "compatibility evidence_ref")
89+
for side in sides:
90+
pin = _fields(declaration[side], _PIN_FIELDS, "source compatibility pin")
91+
for field in _PIN_FIELDS:
92+
if pin[field] is None:
93+
reasons.add(f"{side}_compatibility_{field}_missing")
94+
else:
95+
_text(pin[field], field)
96+
expected = (
97+
operands[side][field]
98+
if field == "source_digest"
99+
else (bases[side][field] if bases[side] is not None else None)
100+
)
101+
if expected is None or pin[field] != expected:
102+
reasons.add(f"{side}_compatibility_{field}_mismatch")
103+
104+
57105
def _component_refs(value: Any, name: str) -> list[str]:
58106
if not isinstance(value, list) or not 1 <= len(value) <= 32:
59107
raise ValueError(f"{name} requires 1..32 unique component references")

0 commit comments

Comments
 (0)