From 93ab8ef0b7718be459fffd8b9ec4224666b09e30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 06:40:05 +0000 Subject: [PATCH] docs: align plan-window and pipeline documentation --- doc/DEPLOYMENT.md | 4 +- finance/config.py | 16 +++--- finance/datasources/jquants.py | 4 +- finance/errors.py | 8 +-- finance/storage.py | 22 ++++---- run.sh | 22 ++++---- test/integration/test_jquants_live.py | 5 +- test/test_plan_window.py | 72 +++++++++++++-------------- 8 files changed, 76 insertions(+), 77 deletions(-) diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index be9aaff..53fb7bd 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -149,8 +149,8 @@ Configuration is optional. Without any, the pipeline writes into the data directory `run.sh` exports, fetches the whole configured plan window, and sends no mail. -The settings worth knowing about are under `jquants`, which describe the plan -rather than the program: +The operational settings below cover the configured publication window and the +J-Quants client's request behavior: | Key | Default | What it is | |---|---|---| diff --git a/finance/config.py b/finance/config.py index 9bd78d4..0e34cef 100644 --- a/finance/config.py +++ b/finance/config.py @@ -185,9 +185,9 @@ def latest_available(self, today: date) -> date: """ Return the newest date the plan publishes as of the given day. - On the Free plan this is twelve weeks back. Asking for anything - newer is answered with nothing, so the pipeline stops at this - date rather than requesting a window it cannot be given. + The configured delay is applied to today. The pipeline stops at + that date rather than requesting a window outside the configured + publication window. """ return today - timedelta(days=self.delay_days) @@ -238,11 +238,11 @@ def fetch_window(self, today: date) -> tuple[date, date]: """ Return the date range a fetch on the given day may ask for. - The end is the newest date the plan publishes. The start is the - configured start date, raised to the oldest date the plan still - keeps: a request for 2014 against a plan holding two years is - not an error, it is simply answered from where the data begins, - and clamping says so in the log instead of implying otherwise. + The end is the newest date the configured plan window permits. + The start is the configured start date, raised to the oldest + date that window keeps. A start date earlier than the window is + not an error; it is clamped to the window and the change is + recorded in the log. """ end = self.jquants.latest_available(today) earliest = self.jquants.earliest_available(today) diff --git a/finance/datasources/jquants.py b/finance/datasources/jquants.py index 2b438ed..19bf225 100644 --- a/finance/datasources/jquants.py +++ b/finance/datasources/jquants.py @@ -261,8 +261,8 @@ def __init__(self, settings: JQuantsSettings, session: object | None = None) -> """ if not settings.has_api_key(): raise AuthenticationError( - "No J-Quants API key is configured. Export JQUANTS_API_KEY with the key " - "issued from the J-Quants dashboard." + "No J-Quants API key is configured. Export JQUANTS_API_KEY with a valid " + "key for the configured J-Quants subscription." ) self.settings = settings self._session = session diff --git a/finance/errors.py b/finance/errors.py index 1a38f8b..1e5f06f 100644 --- a/finance/errors.py +++ b/finance/errors.py @@ -17,10 +17,10 @@ # # The data source errors are split further than the rest because the # operator's response differs by kind. A rejected API key needs a -# configuration change; a rate limit needs a slower schedule; a dataset -# the subscribed plan does not carry needs neither, and is not a fault -# to be fixed. Collapsing them into one type would leave the nightly -# log unable to say which of the three happened. +# configuration change; a rate limit needs a slower request pace; an +# unavailable dataset or date can be skipped; an invalid stock code +# needs its input corrected. Collapsing them into one type would leave +# the nightly log unable to distinguish these conditions. # # Author: id774 (More info: http://id774.net) # Source Code: https://github.com/id774/finance diff --git a/finance/storage.py b/finance/storage.py index ce2353e..cb5af00 100644 --- a/finance/storage.py +++ b/finance/storage.py @@ -5,16 +5,14 @@ # finance/storage.py: File input and output # # Description: -# Every read and write of a generated file passes through here: the raw -# price CSV, the technical indicator CSV, the summary tables, their -# dated copies and the pickled models. +# Centralize file I/O for the raw price CSV, technical indicator CSV, +# summary tables, dated summary copies, data_source.txt and pickled +# models. # -# The formats are a contract with finance-dashboard, so they are stated -# once in this module rather than being spelled out at each call site. -# The two that matter are that a price or indicator file is comma -# separated with a Date index label, and that a summary file is tab -# separated with a Code index label and is read positionally by the -# dashboard. doc/DATA_CONTRACT.md is the normative description. +# File formats shared with finance-dashboard are described normatively +# in doc/DATA_CONTRACT.md. This module keeps the matching separators, +# index labels, data_source.txt file name and key order in constants so +# call sites do not restate them. # # This module decides no path. It is told where to write, so that the # layers above it own the layout and a test can point it at a temporary @@ -217,9 +215,9 @@ def write_data_source( generated: The day the pipeline ran. last_trading_day: The newest trading day the data covers, or None when the run analysed nothing. An unknown date is - written as an empty value rather than as today, because a - consumer showing today for data that is twelve weeks old is - the exact misreading this file exists to prevent. + written as an empty value rather than as today, because + substituting the run date would make older or unknown data + look more current than it is. Raises: StorageError: The file cannot be written. diff --git a/run.sh b/run.sh index ecdc0c2..39f224f 100755 --- a/run.sh +++ b/run.sh @@ -9,15 +9,14 @@ # indicator files, then the mail that reads the summaries, and last the # long and short charts, which nothing else consumes. # -# That order is a specification, not a habit. Steps 2 to 6 read the -# ti_CODE.csv files step 1 writes, and steps 7 and 8 read the summaries -# steps 2 and 4 write. +# That order is a specification, not a habit. The summary runs read the +# ti_CODE.csv files written by the updating chart run, and the mail runs +# read the summaries produced earlier in the job. # -# Only step 1 passes --update. It is the run that fetches prices, +# Only the updating chart run passes --update. It fetches prices, # rewrites stock_CODE.csv and ti_CODE.csv, retrains the models and -# records where the data came from. The long and short runs draw their -# charts from what step 1 already stored, so the job makes one pass -# over the network per stock per day rather than three, and needs no +# records where the data came from. The long and short chart runs draw +# from the stored data, so they perform no additional fetch and need no # API key of their own. # # A step that fails is reported and the job continues to the next one, @@ -39,10 +38,11 @@ # ./run.sh -h | --help # # Environment Variables: -# - JQUANTS_API_KEY: The J-Quants API key. Required by step 1, which is -# the only step that fetches. It is not set here: put it in an -# environment file the deployment owns, readable only by the user -# the job runs as, and never in this script or in the repository. +# - JQUANTS_API_KEY: The J-Quants API key. Required by the updating +# chart run, which is the only run that fetches. It is not set here: +# put it in an environment file the deployment owns, readable only +# by the user the job runs as, and never in this script or in the +# repository. # - WORK_DIR: Deployment root. Defaults to /var/stock. # - ENV_FILE: A file of KEY=value lines sourced before the job, holding # JQUANTS_API_KEY. Defaults to $WORK_DIR/env, and is skipped when diff --git a/test/integration/test_jquants_live.py b/test/integration/test_jquants_live.py index 0fa33fe..a351b60 100644 --- a/test/integration/test_jquants_live.py +++ b/test/integration/test_jquants_live.py @@ -23,8 +23,9 @@ # test/test_datasources.py are what the adapter is really tested # against. # -# The date range asked for is inside the Free plan's published window, -# so that a Free subscription can run this as well as a paid one. +# The date range is derived from the configured publication window. +# The test does not hard-code a provider plan's current delay or compare +# subscription tiers. # # Test Cases: # - The endpoint answers and the response normalizes to the canonical diff --git a/test/test_plan_window.py b/test/test_plan_window.py index 867fbf1..82378fa 100644 --- a/test/test_plan_window.py +++ b/test/test_plan_window.py @@ -9,17 +9,14 @@ # plan decide the dates a run may ask for, and that the rest of the # pipeline behaves correctly at the edges of that window. # -# These are the tests of the constraint the Free plan imposes. It -# publishes nothing newer than twelve weeks ago and keeps two years -# behind that, and this repository treats both as the specification -# rather than as a defect to work around. What that has to mean in -# practice is asserted here: a fetch is never asked for a date the plan -# cannot answer, a start date older than the plan is raised rather than -# refused, stored data that already reaches the newest published date -# is left alone instead of being re-requested every night, and a -# summary measures staleness against that date rather than against -# today -- which, if it did not, would drop every stock as stale and -# write an empty table every evening. +# These tests use an explicit publication-delay and retention fixture. +# Its values are test inputs and program defaults, not assertions about +# current provider plan terms. The behavior under that configured +# window is asserted here: a fetch is never asked for a date outside +# the window, a start date older than the window is raised to its lower +# bound, stored data that reaches the newest configured date is left +# alone instead of being re-requested every night, and a summary +# measures staleness against that date rather than against today. # # Test Cases: # - The window ends at the newest date the plan publishes. @@ -62,41 +59,43 @@ TODAY = date(2026, 8, 14) -FREE_PLAN = JQuantsSettings(api_key="test-key", delay_days=84, retention_days=730) +TEST_PLAN = JQuantsSettings(api_key="test-key", delay_days=84, retention_days=730) -def free(settings): - """ Return the settings with the Free plan window in place. """ - return replace(settings, jquants=FREE_PLAN, start_date="") +def with_test_plan(settings): + """ Return the settings with the test plan window in place. """ + return replace(settings, jquants=TEST_PLAN, start_date="") def test_the_window_ends_at_the_newest_published_date(): - assert FREE_PLAN.latest_available(TODAY) == TODAY - timedelta(days=84) + assert TEST_PLAN.latest_available(TODAY) == TODAY - timedelta(days=84) def test_the_window_begins_at_the_oldest_kept_date(): - assert FREE_PLAN.earliest_available(TODAY) == TODAY - timedelta(days=84 + 730) + assert TEST_PLAN.earliest_available(TODAY) == TODAY - timedelta(days=84 + 730) def test_a_start_date_inside_the_window_is_honoured(settings): inside = (TODAY - timedelta(days=200)).isoformat() - start, end = replace(free(settings), start_date=inside).fetch_window(TODAY) + start, end = replace(with_test_plan(settings), start_date=inside).fetch_window(TODAY) assert start.isoformat() == inside - assert end == FREE_PLAN.latest_available(TODAY) + assert end == TEST_PLAN.latest_available(TODAY) def test_a_start_date_before_the_window_is_raised_to_it(settings): # The value the pipeline used when its source could answer for 2014. - older = replace(free(settings), start_date="2014-10-01") + older = replace(with_test_plan(settings), start_date="2014-10-01") start, _ = older.fetch_window(TODAY) - assert start == FREE_PLAN.earliest_available(TODAY) + assert start == TEST_PLAN.earliest_available(TODAY) def test_a_plan_without_a_delay_ends_the_window_at_today(settings): - paid = replace(free(settings), jquants=replace(FREE_PLAN, delay_days=0)) - _, end = paid.fetch_window(TODAY) + no_delay = replace( + with_test_plan(settings), jquants=replace(TEST_PLAN, delay_days=0) + ) + _, end = no_delay.fetch_window(TODAY) assert end == TODAY @@ -110,24 +109,24 @@ def shifted(frame, last: date): def test_a_fetch_is_asked_for_the_window_and_never_beyond_it( settings, stub_source, raw_prices ): - plan = free(settings) - source = stub_source(shifted(raw_prices, FREE_PLAN.latest_available(TODAY))) + plan = with_test_plan(settings) + source = stub_source(shifted(raw_prices, TEST_PLAN.latest_available(TODAY))) Analysis(plan, source, today=TODAY).run( AnalysisRequest(code="7203", days=60, update=True) ) code, start, end = source.calls[0] assert code == "7203" - assert start == FREE_PLAN.earliest_available(TODAY) - assert end == FREE_PLAN.latest_available(TODAY) + assert start == TEST_PLAN.earliest_available(TODAY) + assert end == TEST_PLAN.latest_available(TODAY) assert end < TODAY def test_stored_data_reaching_the_published_date_is_not_re_requested( settings, stub_source, raw_prices ): - plan = free(settings) - published = FREE_PLAN.latest_available(TODAY) + plan = with_test_plan(settings) + published = TEST_PLAN.latest_available(TODAY) # Stored history whose last row is the newest date the plan has. write_price_csv(shifted(raw_prices, published), plan.data_file("stock_7203.csv")) @@ -143,8 +142,8 @@ def test_stored_data_reaching_the_published_date_is_not_re_requested( def test_staleness_is_measured_against_the_published_date( settings, stub_source, raw_prices, indicator_fixture ): - plan = free(settings) - published = FREE_PLAN.latest_available(TODAY) + plan = with_test_plan(settings) + published = TEST_PLAN.latest_available(TODAY) shifted(indicator_fixture, published).to_csv( plan.data_file("ti_7203.csv"), index_label="Date" @@ -155,7 +154,7 @@ def test_staleness_is_measured_against_the_published_date( plan, SummaryRequest(output="summary.csv", stock_list="stocks.txt"), today=TODAY ) - # Twelve weeks old against today, and current against the plan. + # The delayed fixture is current against the configured plan window. assert result.rows == 1 @@ -163,9 +162,10 @@ def test_the_longest_lookback_fits_inside_the_window(): """ The longest moving average must be computable within the plan. - Two years is about 488 trading days; the longest window the - indicator layer asks for is 200. If a future plan change made this - false, every long indicator would silently become all-NaN. + The configured retention window is converted to an approximate + weekday count. The longest indicator window must fit inside it; if + a future configuration change made this false, every long indicator + would silently become all-NaN. """ - trading_days = (FREE_PLAN.retention_days / 7) * 5 + trading_days = (TEST_PLAN.retention_days / 7) * 5 assert max(SMA_PERIODS) < trading_days